@hone-ai/cli 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ 'use strict';
2
+ /**
3
+ * materialize-diff.js — HC-019n-followup-23 (pipeline-recovery condition 5).
4
+ *
5
+ * step_4 stopped hand-writing unified diffs and now emits WHOLE-FILE contents
6
+ * (see the ## Changed Files prompt contract). The reason: an LLM counts a short
7
+ * hunk header correctly and a long one wrong — confirmation run 2c5065ad claimed
8
+ * `@@ -0,0 +1,65 @@` for a block that actually had 55 lines, and no prompt
9
+ * wording fixes that. So the LLM writes files (what it is good at) and `git diff`
10
+ * computes the hunks (what it is good at, and authoritatively — a diff git
11
+ * produces is one `git apply` accepts by construction).
12
+ *
13
+ * This module is split like patch-apply.js: the PURE half (parsing the file
14
+ * blocks out of step_4 output) is here and unit-tested; the git invocation that
15
+ * turns a candidate file into a diff needs a real tree and lives in the CLI
16
+ * command, calling buildFileDiff() with an injected runner so it too can be
17
+ * tested.
18
+ *
19
+ * See .github/pipeline/HC-019n-followup-23/architect.md for why whole-file
20
+ * emission (Option A) beat structured old/new-string edits (Option B).
21
+ */
22
+ const path = require('node:path');
23
+
24
+ /**
25
+ * A ```file:<path> fenced block, capturing the path and the verbatim body.
26
+ * Info-string tolerant: ```file:foo, ``` file: foo, ```FILE:foo all match.
27
+ */
28
+ const FILE_BLOCK_RE = /```[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)\r?\n```/gi;
29
+
30
+ /**
31
+ * A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
32
+ * bundle whole (>25K chars, truncated/windowed), step_4 cannot emit a whole-file
33
+ * block (it never saw the whole file). Instead it emits a REGION edit: a unique
34
+ * OLD anchor (from the excerpt it WAS shown) and its NEW replacement, framed with
35
+ * conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and
36
+ * lets git build the diff — so the count stays git's, exactly as for whole-file
37
+ * blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
38
+ */
39
+ const EDIT_BLOCK_RE = /```[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)\r?\n```/gi;
40
+
41
+ /**
42
+ * The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
43
+ * Whitespace after the marker word is tolerated; the bodies are captured verbatim
44
+ * (indentation is part of the anchor).
45
+ */
46
+ const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n([\s\S]*?)\r?\n>{3,}[ \t]*NEW[ \t]*$/m;
47
+
48
+ /** A `DELETE: <path>` line inside the ## Changed Files section. */
49
+ const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
50
+
51
+ /**
52
+ * Typed error for the region-edit apply path, so a consumer can map it to a
53
+ * distinct exit code and a message that tells step_4 how to fix its anchor.
54
+ * `.code` is one of 'anchor_not_found' | 'anchor_ambiguous' | 'file_not_found'.
55
+ */
56
+ class MaterializeError extends Error {
57
+ constructor(code, message) {
58
+ super(message);
59
+ this.name = 'MaterializeError';
60
+ this.code = code;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Parse step_4 output into a set of intended file states.
66
+ *
67
+ * @param {string} output raw step_4 output
68
+ * @returns {{
69
+ * files: Array<{ path: string, contents: string }>,
70
+ * deletes: string[],
71
+ * noChanges: boolean,
72
+ * reason: string|null
73
+ * }}
74
+ */
75
+ function extractFileBlocks(output) {
76
+ const text = String(output || '');
77
+ const empty = { files: [], edits: [], deletes: [], noChanges: false, reason: null };
78
+
79
+ // Scope everything to the ## Changed Files section if present, so a code
80
+ // sample elsewhere in the prose is never mistaken for an intended file.
81
+ const secIdx = text.search(/^##\s+Changed Files/im);
82
+ const scope = secIdx === -1 ? text : text.slice(secIdx);
83
+
84
+ // Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
85
+ // declaration only counts when there is NO file AND NO edit block to apply.
86
+ const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_BLOCK_RE.test(scope));
87
+ FILE_BLOCK_RE.lastIndex = 0; EDIT_BLOCK_RE.lastIndex = 0;
88
+ const noChange = scope.match(/NO CHANGES\b[ \t]*[—:-]?[ \t]*([^\n]*)/i);
89
+ if (noChange && !hasBlock) {
90
+ return { ...empty, noChanges: true, reason: (noChange[1] || '').trim() || null };
91
+ }
92
+
93
+ const files = [];
94
+ const seen = new Set();
95
+ for (const m of scope.matchAll(FILE_BLOCK_RE)) {
96
+ const rel = normalizeRepoPath(m[1]);
97
+ if (!rel) continue; // rejected (traversal/absolute) — see below
98
+ if (seen.has(rel)) continue; // first block wins on a dup path
99
+ seen.add(rel);
100
+ files.push({ path: rel, contents: m[2] });
101
+ }
102
+
103
+ // HC-019n-followup-27: region edits for files too big to bundle whole.
104
+ const edits = [];
105
+ const editSeen = new Set();
106
+ for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
107
+ const rel = normalizeRepoPath(m[1]);
108
+ if (!rel) continue;
109
+ if (seen.has(rel) || editSeen.has(rel)) continue; // a whole-file block wins; first edit wins
110
+ const split = EDIT_SPLIT_RE.exec(m[2]);
111
+ if (!split) continue; // malformed edit block (no OLD/NEW markers) — skip
112
+ editSeen.add(rel);
113
+ edits.push({ path: rel, oldString: split[1], newString: split[2] });
114
+ }
115
+
116
+ const deletes = [];
117
+ for (const m of scope.matchAll(DELETE_RE)) {
118
+ const rel = normalizeRepoPath(m[1]);
119
+ if (rel && !deletes.includes(rel)) deletes.push(rel);
120
+ }
121
+
122
+ return { files, edits, deletes, noChanges: false, reason: null };
123
+ }
124
+
125
+ /**
126
+ * Apply a region edit to the real full file contents, deterministically.
127
+ *
128
+ * The OLD anchor MUST occur exactly once — this is the entire safety property.
129
+ * Zero matches (step_4 hallucinated the anchor, or the file drifted) or many
130
+ * matches (ambiguous — applying to the wrong instance would corrupt) are hard
131
+ * errors, never a silent partial write. The count check is against the REAL
132
+ * full file, so a window-unique-but-file-ambiguous anchor is still caught.
133
+ *
134
+ * @param {string} fileContents real full file contents
135
+ * @param {string} oldString the OLD anchor (verbatim)
136
+ * @param {string} newString the NEW replacement
137
+ * @param {string} repoPath for error messages
138
+ * @returns {string} the candidate file contents (OLD replaced by NEW, once)
139
+ * @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
140
+ */
141
+ function applyEdit(fileContents, oldString, newString, repoPath) {
142
+ const src = String(fileContents);
143
+ const anchor = String(oldString);
144
+ if (!anchor) {
145
+ throw new MaterializeError('anchor_not_found', `${repoPath}: edit block has an empty OLD anchor`);
146
+ }
147
+ const count = countOccurrences(src, anchor);
148
+ if (count === 0) {
149
+ const firstLine = anchor.split('\n').find(l => l.trim()) || anchor.slice(0, 60);
150
+ throw new MaterializeError('anchor_not_found',
151
+ `${repoPath}: OLD anchor not found (starts: ${JSON.stringify(firstLine.trim().slice(0, 60))}). ` +
152
+ `The file may have changed, or the anchor was not copied verbatim.`);
153
+ }
154
+ if (count > 1) {
155
+ throw new MaterializeError('anchor_ambiguous',
156
+ `${repoPath}: OLD anchor matched ${count} times — include more surrounding context so it is unique.`);
157
+ }
158
+ const idx = src.indexOf(anchor);
159
+ return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
160
+ }
161
+
162
+ /** Non-overlapping occurrence count of `needle` in `hay` (literal, not regex). */
163
+ function countOccurrences(hay, needle) {
164
+ if (!needle) return 0;
165
+ let n = 0, i = 0;
166
+ for (;;) {
167
+ const at = hay.indexOf(needle, i);
168
+ if (at === -1) return n;
169
+ n += 1;
170
+ i = at + needle.length;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Assemble one unified diff from parsed blocks — the SHARED materialization used
176
+ * by check-patch, verify-patch, and emit-pr (HC-019n-followup-27 de-triplicated
177
+ * what was three copies of this loop). Whole-file blocks, region edits, and
178
+ * deletes all resolve to git-authored hunks.
179
+ *
180
+ * Injected primitives keep it testable without a real repo:
181
+ * - runGitDiff(origArg, candidatePath) → { stdout, code } (git diff --no-index)
182
+ * - readFile(repoRelPath) → string|null (real full file, or null)
183
+ * - exists(repoRelPath) → boolean
184
+ * - tmpFile(contents) → { path, cleanup } (write a temp candidate)
185
+ *
186
+ * @returns {{ diff: string, partCount: number }}
187
+ * @throws {MaterializeError} from the region-edit apply path
188
+ */
189
+ function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
190
+ const parts = [];
191
+
192
+ // 1. Whole-file blocks (followup-23 Option A) — unchanged behaviour.
193
+ for (const f of blocks.files) {
194
+ const isNew = !exists(f.path);
195
+ const t = tmpFile(f.contents.endsWith('\n') ? f.contents : f.contents + '\n');
196
+ try {
197
+ const built = buildFileDiff({
198
+ repoPath: f.path, isNew,
199
+ runGitDiff: () => runGitDiff(isNew ? '/dev/null' : f.path, t.path),
200
+ });
201
+ if (built.changed) parts.push(built.diff);
202
+ } finally { t.cleanup(); }
203
+ }
204
+
205
+ // 2. Region edits (followup-27) — apply OLD→NEW to the REAL file, git-diff it.
206
+ for (const e of blocks.edits || []) {
207
+ const real = readFile(e.path);
208
+ if (real == null) {
209
+ throw new MaterializeError('file_not_found',
210
+ `${e.path}: cannot apply edit — file not found in the working tree`);
211
+ }
212
+ const candidate = applyEdit(real, e.oldString, e.newString, e.path); // throws typed on 0/many
213
+ const t = tmpFile(candidate.endsWith('\n') ? candidate : candidate + '\n');
214
+ try {
215
+ const built = buildFileDiff({
216
+ repoPath: e.path, isNew: false,
217
+ runGitDiff: () => runGitDiff(e.path, t.path),
218
+ });
219
+ if (built.changed) parts.push(built.diff);
220
+ } finally { t.cleanup(); }
221
+ }
222
+
223
+ // 3. Deletions — a whole-file removal hunk against the real file on disk.
224
+ for (const del of blocks.deletes || []) {
225
+ if (!exists(del)) continue;
226
+ const body = readFile(del);
227
+ if (body == null) continue;
228
+ const lines = body.replace(/\n$/, '').split('\n');
229
+ parts.push(
230
+ `diff --git a/${del} b/${del}\n--- a/${del}\n+++ /dev/null\n` +
231
+ `@@ -1,${lines.length} +0,0 @@\n` + lines.map(l => `-${l}`).join('\n') + '\n');
232
+ }
233
+
234
+ return { diff: parts.join(''), partCount: parts.length };
235
+ }
236
+
237
+ /**
238
+ * Reject anything that could escape the repo root; normalize separators.
239
+ * Returns null for an unsafe or empty path.
240
+ */
241
+ function normalizeRepoPath(p) {
242
+ const raw = String(p || '').trim().replace(/^["'`]|["'`]$/g, '');
243
+ if (!raw) return null;
244
+ if (raw.startsWith('/') || raw.startsWith('~') || raw.includes('..')) return null;
245
+ // Collapse ./ and duplicate slashes without resolving against the FS.
246
+ const norm = path.posix.normalize(raw).replace(/^\.\//, '');
247
+ if (norm.startsWith('/') || norm.startsWith('..')) return null;
248
+ return norm;
249
+ }
250
+
251
+ /**
252
+ * Rewrite the temp paths in a `git diff --no-index` result to the real repo
253
+ * path. git emits `--- a/<tmp>` / `+++ b/<tmp>` (and a `diff --git` line); the
254
+ * assembled patch must name the repo-relative path so it applies at repo root.
255
+ *
256
+ * @param {string} rawDiff output of `git diff --no-index <orig> <candidate>`
257
+ * @param {string} repoPath repo-relative destination path
258
+ * @param {boolean} isNew true when the original did not exist (create-file)
259
+ * @returns {string}
260
+ */
261
+ function rewriteDiffPaths(rawDiff, repoPath, isNew) {
262
+ if (!rawDiff) return '';
263
+ const src = isNew ? '/dev/null' : `a/${repoPath}`;
264
+ return rawDiff
265
+ .replace(/^diff --git .*$/m, `diff --git a/${repoPath} b/${repoPath}`)
266
+ .replace(/^--- .*$/m, `--- ${src}`)
267
+ .replace(/^\+\+\+ .*$/m, `+++ b/${repoPath}`);
268
+ }
269
+
270
+ /**
271
+ * Assemble a full unified diff for one file, given an injected git runner.
272
+ *
273
+ * The runner takes (origPathOrDevNull, candidatePath) and returns
274
+ * { stdout, code } from `git diff --no-index` (which exits 1 when they differ,
275
+ * 0 when identical — NOT an error). Kept injectable so this is unit-testable
276
+ * without a real FS; the CLI passes a runner that writes temp files and shells
277
+ * out to git.
278
+ *
279
+ * @returns {{ diff: string, changed: boolean }}
280
+ */
281
+ function buildFileDiff({ repoPath, isNew, runGitDiff }) {
282
+ const { stdout, code } = runGitDiff();
283
+ // code 0 => no difference (candidate equals original) => nothing to emit.
284
+ if (code === 0 || !stdout || !stdout.trim()) {
285
+ return { diff: '', changed: false };
286
+ }
287
+ return { diff: ensureTrailingNewline(rewriteDiffPaths(stdout, repoPath, isNew)), changed: true };
288
+ }
289
+
290
+ function ensureTrailingNewline(s) {
291
+ return s.replace(/\s*$/, '') + '\n';
292
+ }
293
+
294
+ /**
295
+ * The real node-backed IO for buildDiff: reads the adopter's files off disk,
296
+ * writes temp candidates, and shells out to `git diff --no-index` with the
297
+ * ambient git env stripped (gitEnv). Lazily requires its deps so this module
298
+ * stays side-effect-free to import for the pure-function unit tests.
299
+ *
300
+ * @param {{cwd?: string}} [o]
301
+ * @returns {{ exists: Function, readFile: Function, tmpFile: Function, runGitDiff: Function }}
302
+ */
303
+ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
304
+ const fs = require('node:fs');
305
+ const os = require('node:os');
306
+ const p = require('node:path');
307
+ const { execFileSync } = require('node:child_process');
308
+ const { gitEnv } = require('./git-env');
309
+ let n = 0;
310
+ return {
311
+ exists: (rel) => fs.existsSync(p.resolve(cwd, rel)),
312
+ readFile: (rel) => {
313
+ const abs = p.resolve(cwd, rel);
314
+ return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
315
+ },
316
+ tmpFile: (contents) => {
317
+ const tp = p.join(os.tmpdir(), `hone-mat-${process.pid}-${Date.now()}-${n++}`);
318
+ fs.writeFileSync(tp, contents);
319
+ return { path: tp, cleanup: () => { try { fs.unlinkSync(tp); } catch { /* best-effort */ } } };
320
+ },
321
+ runGitDiff: (origArg, candPath) => {
322
+ let stdout = '', code = 0;
323
+ try {
324
+ stdout = execFileSync('git', ['diff', '--no-index', '--', origArg, candPath],
325
+ { cwd, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
326
+ } catch (e) { code = e.status || 1; stdout = (e.stdout || '').toString(); }
327
+ return { stdout, code };
328
+ },
329
+ };
330
+ }
331
+
332
+ module.exports = {
333
+ extractFileBlocks,
334
+ normalizeRepoPath,
335
+ rewriteDiffPaths,
336
+ buildFileDiff,
337
+ buildDiff,
338
+ nodeMaterializeIO,
339
+ applyEdit,
340
+ countOccurrences,
341
+ ensureTrailingNewline,
342
+ MaterializeError,
343
+ FILE_BLOCK_RE,
344
+ EDIT_BLOCK_RE,
345
+ };
@@ -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
+ };