@fideliosai/adapter-hermes-local 0.0.41 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fideliosai/adapter-hermes-local",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "license": "MIT",
5
5
  "description": "Hermes AI agent adapter for FideliOS",
6
6
  "homepage": "https://github.com/fideliosai/fidelios",
@@ -23,7 +23,14 @@
23
23
  "src"
24
24
  ],
25
25
  "dependencies": {
26
- "@fideliosai/adapter-utils": "0.0.41",
26
+ "@fideliosai/adapter-utils": "0.0.43",
27
+ "ollama": "^0.6.3",
27
28
  "picocolors": "^1.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "vitest": "^3.2.4"
32
+ },
33
+ "scripts": {
34
+ "test": "vitest run"
28
35
  }
29
36
  }
@@ -17,6 +17,9 @@
17
17
  */
18
18
  import { runChildProcess, buildFideliOSEnv, renderTemplate, ensureAbsoluteDirectory, } from "@fideliosai/adapter-utils/server-utils";
19
19
  import { HERMES_CLI, DEFAULT_TIMEOUT_SEC, DEFAULT_GRACE_SEC, VALID_PROVIDERS, } from "../shared/constants.js";
20
+ import { triageToolsets } from "./triage.js";
21
+ import { HERMES_TOOLSET_REGISTRY } from "./toolset-registry.js";
22
+ import { isHeadlessEnv, filterHeadlessCsv } from "./headless.js";
20
23
  // ---------------------------------------------------------------------------
21
24
  // Config helpers
22
25
  // ---------------------------------------------------------------------------
@@ -74,6 +77,18 @@ Someone commented. Read it:
74
77
  Address the comment, POST a reply if needed, then continue working.
75
78
  {{/commentId}}
76
79
 
80
+ {{#taskId}}
81
+ ## If you need clarification
82
+
83
+ You are running unattended — there is no human at the terminal, so do NOT use \`clarify\` (it has been stripped from your toolset for this reason). If the task is genuinely ambiguous and you cannot proceed safely, escalate via the FideliOS API:
84
+
85
+ 1. Post the question as a comment:
86
+ \`curl -s -X POST "{{fideliosApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"❓ Question: <your question>"}'\`
87
+ 2. Mark the issue blocked:
88
+ \`curl -s -X PATCH "{{fideliosApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"blocked"}'\`
89
+ 3. Stop. The Board will answer in a comment; FideliOS will wake you on the next heartbeat with the reply attached. Prefer this over making large irreversible decisions on shaky assumptions.
90
+ {{/taskId}}
91
+
77
92
  {{#noTask}}
78
93
  ## Heartbeat Wake — Check for Work
79
94
 
@@ -248,13 +263,56 @@ export async function execute(ctx) {
248
263
  const provider = cfgString(config.provider);
249
264
  const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
250
265
  const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
251
- const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
266
+ const explicitToolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
267
+ let toolsets = explicitToolsets;
268
+ let triageMeta = null;
252
269
  const extraArgs = cfgStringArray(config.extraArgs);
253
270
  const persistSession = cfgBoolean(config.persistSession) !== false;
254
271
  const worktreeMode = cfgBoolean(config.worktreeMode) === true;
255
272
  const checkpoints = cfgBoolean(config.checkpoints) === true;
256
273
  // ── Build prompt ───────────────────────────────────────────────────────
257
274
  const prompt = buildPrompt(ctx, config);
275
+ // ── Triage toolsets (FID-48) ───────────────────────────────────────────
276
+ // If the operator pinned toolsets explicitly, respect that whitelist
277
+ // (backwards compat). Otherwise ask the configured local model to pick a
278
+ // relevant subset for this prompt out of all available Hermes toolsets.
279
+ const triageEnabled = cfgBoolean(config.triageEnabled) !== false; // default on
280
+ if (!explicitToolsets && triageEnabled) {
281
+ const triageModel = cfgString(config.triageModel) || model;
282
+ const triageTimeoutMs = cfgNumber(config.triageTimeoutMs);
283
+ const triageHost = cfgString(config.triageHost) || cfgString(config.ollamaHost);
284
+ triageMeta = await triageToolsets({
285
+ prompt,
286
+ model: triageModel,
287
+ host: triageHost,
288
+ timeoutMs: triageTimeoutMs,
289
+ });
290
+ if (triageMeta.toolsets.length > 0) {
291
+ toolsets = triageMeta.toolsets.join(",");
292
+ }
293
+ const total = HERMES_TOOLSET_REGISTRY.length;
294
+ await ctx.onLog("stdout", `[hermes-triage] selected: ${triageMeta.toolsets.join(",") || "(none)"} (${triageMeta.toolsets.length} of ${total}, ${triageMeta.durationMs}ms${triageMeta.usedFallback ? `, fallback: ${triageMeta.error}` : ""})\n`);
295
+ }
296
+ // ── Headless I/O contract (FID-52) ─────────────────────────────────────
297
+ // Strip toolsets that block on stdin (e.g. `clarify`) when running
298
+ // unattended. Without this, hermes hangs ~120 s per clarify call up to
299
+ // the adapter's hard timeout (FID-47). The model is instead expected to
300
+ // escalate via the FideliOS API — the prompt template carries that
301
+ // guidance.
302
+ let headlessMeta = null;
303
+ if (isHeadlessEnv(process.env, { stdinIsTTY: process.stdin?.isTTY })) {
304
+ const { csv, stripped } = filterHeadlessCsv(toolsets);
305
+ headlessMeta = {
306
+ headless: true,
307
+ stripped,
308
+ explicitOverride: !!explicitToolsets && stripped.length > 0,
309
+ };
310
+ if (stripped.length > 0) {
311
+ const noun = stripped.length === 1 ? "toolset" : "toolsets";
312
+ await ctx.onLog("stdout", `[hermes-headless] stripped interactive ${noun}: ${stripped.join(",")} (escalate via FideliOS API instead)\n`);
313
+ toolsets = csv || undefined;
314
+ }
315
+ }
258
316
  // ── Build command args ─────────────────────────────────────────────────
259
317
  // Use -Q (quiet) to get clean output: just response + session_id line
260
318
  const useQuiet = cfgBoolean(config.quiet) !== false; // default true
@@ -400,6 +458,21 @@ export async function execute(ctx) {
400
458
  session_id: parsed.sessionId || null,
401
459
  usage: parsed.usage || null,
402
460
  cost_usd: parsed.costUsd ?? null,
461
+ triage: triageMeta
462
+ ? {
463
+ toolsets: triageMeta.toolsets,
464
+ used_fallback: triageMeta.usedFallback,
465
+ error: triageMeta.error ?? null,
466
+ duration_ms: triageMeta.durationMs,
467
+ }
468
+ : null,
469
+ headless: headlessMeta
470
+ ? {
471
+ headless: headlessMeta.headless,
472
+ stripped: headlessMeta.stripped,
473
+ explicit_override: headlessMeta.explicitOverride,
474
+ }
475
+ : null,
403
476
  };
404
477
  // Store session ID for next run
405
478
  if (persistSession && parsed.sessionId) {
@@ -0,0 +1,324 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Module mocks must be declared before importing the SUT.
4
+ const runChildProcessMock = vi.fn();
5
+ const triageToolsetsMock = vi.fn();
6
+
7
+ vi.mock("@fideliosai/adapter-utils/server-utils", async () => {
8
+ const actual = await vi.importActual(
9
+ "@fideliosai/adapter-utils/server-utils"
10
+ );
11
+ return {
12
+ ...actual,
13
+ runChildProcess: (...args) => runChildProcessMock(...args),
14
+ ensureAbsoluteDirectory: vi.fn(async () => undefined),
15
+ };
16
+ });
17
+
18
+ vi.mock("./triage.js", () => ({
19
+ triageToolsets: (...args) => triageToolsetsMock(...args),
20
+ }));
21
+
22
+ const { execute } = await import("./execute.js");
23
+
24
+ function makeCtx(overrides = {}) {
25
+ const logs = [];
26
+ const ctx = {
27
+ runId: "run-1",
28
+ authToken: "tok",
29
+ agent: {
30
+ id: "agent-1",
31
+ companyId: "co-1",
32
+ name: "TestAgent",
33
+ adapterConfig: {
34
+ hermesCommand: "echo", // anything; we mock runChildProcess
35
+ model: "qwen3:4b",
36
+ ...overrides.adapterConfig,
37
+ },
38
+ },
39
+ config: {
40
+ ...overrides.config,
41
+ },
42
+ runtime: {},
43
+ onLog: async (stream, chunk) => {
44
+ logs.push({ stream, chunk });
45
+ },
46
+ };
47
+ return { ctx, logs };
48
+ }
49
+
50
+ beforeEach(() => {
51
+ runChildProcessMock.mockReset();
52
+ triageToolsetsMock.mockReset();
53
+ runChildProcessMock.mockResolvedValue({
54
+ stdout: "ok\n\nsession_id: sess-1\n",
55
+ stderr: "",
56
+ exitCode: 0,
57
+ signal: null,
58
+ timedOut: false,
59
+ });
60
+ });
61
+
62
+ function getArgs() {
63
+ expect(runChildProcessMock).toHaveBeenCalledTimes(1);
64
+ // (runId, cmd, args, opts)
65
+ return runChildProcessMock.mock.calls[0][2];
66
+ }
67
+
68
+ function findToolsetsArg(args) {
69
+ const idx = args.indexOf("-t");
70
+ return idx === -1 ? null : args[idx + 1];
71
+ }
72
+
73
+ describe("execute: explicit toolsets bypass triage (backwards-compat)", () => {
74
+ it("respects adapterConfig.toolsets and never calls triage", async () => {
75
+ const { ctx } = makeCtx({
76
+ adapterConfig: { toolsets: "file,terminal" },
77
+ });
78
+
79
+ await execute(ctx);
80
+
81
+ expect(triageToolsetsMock).not.toHaveBeenCalled();
82
+ expect(findToolsetsArg(getArgs())).toBe("file,terminal");
83
+ });
84
+
85
+ it("respects adapterConfig.enabledToolsets array form", async () => {
86
+ const { ctx } = makeCtx({
87
+ adapterConfig: { enabledToolsets: ["file", "web"] },
88
+ });
89
+
90
+ await execute(ctx);
91
+
92
+ expect(triageToolsetsMock).not.toHaveBeenCalled();
93
+ expect(findToolsetsArg(getArgs())).toBe("file,web");
94
+ });
95
+
96
+ it("does not invoke triage when triageEnabled === false", async () => {
97
+ const { ctx } = makeCtx({
98
+ adapterConfig: { triageEnabled: false },
99
+ });
100
+
101
+ await execute(ctx);
102
+
103
+ expect(triageToolsetsMock).not.toHaveBeenCalled();
104
+ // No -t flag — Hermes will use its default enabled set.
105
+ expect(findToolsetsArg(getArgs())).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe("execute: auto-triage path", () => {
110
+ it("calls triage with the configured model when toolsets is unset", async () => {
111
+ triageToolsetsMock.mockResolvedValue({
112
+ toolsets: ["file", "terminal"],
113
+ usedFallback: false,
114
+ durationMs: 42,
115
+ });
116
+
117
+ const { ctx } = makeCtx();
118
+
119
+ await execute(ctx);
120
+
121
+ expect(triageToolsetsMock).toHaveBeenCalledTimes(1);
122
+ const triageArgs = triageToolsetsMock.mock.calls[0][0];
123
+ expect(triageArgs.model).toBe("qwen3:4b");
124
+ expect(typeof triageArgs.prompt).toBe("string");
125
+ expect(triageArgs.prompt.length).toBeGreaterThan(0);
126
+
127
+ expect(findToolsetsArg(getArgs())).toBe("file,terminal");
128
+ });
129
+
130
+ it("uses adapterConfig.triageModel override when set", async () => {
131
+ triageToolsetsMock.mockResolvedValue({
132
+ toolsets: ["file"],
133
+ usedFallback: false,
134
+ durationMs: 10,
135
+ });
136
+
137
+ const { ctx } = makeCtx({
138
+ adapterConfig: { triageModel: "qwen3:0.6b" },
139
+ });
140
+
141
+ await execute(ctx);
142
+
143
+ expect(triageToolsetsMock.mock.calls[0][0].model).toBe("qwen3:0.6b");
144
+ });
145
+
146
+ it("forwards triageHost / ollamaHost / triageTimeoutMs into triage opts", async () => {
147
+ triageToolsetsMock.mockResolvedValue({
148
+ toolsets: ["file"],
149
+ usedFallback: false,
150
+ durationMs: 5,
151
+ });
152
+
153
+ const { ctx } = makeCtx({
154
+ adapterConfig: {
155
+ ollamaHost: "http://localhost:11434",
156
+ triageTimeoutMs: 5000,
157
+ },
158
+ });
159
+
160
+ await execute(ctx);
161
+
162
+ const opts = triageToolsetsMock.mock.calls[0][0];
163
+ expect(opts.host).toBe("http://localhost:11434");
164
+ expect(opts.timeoutMs).toBe(5000);
165
+ });
166
+
167
+ it("emits a banner log including the selected subset and total registry size", async () => {
168
+ triageToolsetsMock.mockResolvedValue({
169
+ toolsets: ["file", "web"],
170
+ usedFallback: false,
171
+ durationMs: 17,
172
+ });
173
+
174
+ const { ctx, logs } = makeCtx();
175
+ await execute(ctx);
176
+
177
+ const banner = logs.find((l) =>
178
+ l.chunk.startsWith("[hermes-triage] selected: file,web")
179
+ );
180
+ expect(banner).toBeTruthy();
181
+ // total registry size of 22 (current Hermes v0.12)
182
+ expect(banner.chunk).toMatch(/\(2 of \d+,/);
183
+ expect(banner.chunk).toMatch(/17ms/);
184
+ });
185
+
186
+ it("surfaces triage metadata in resultJson.triage", async () => {
187
+ triageToolsetsMock.mockResolvedValue({
188
+ toolsets: ["file"],
189
+ usedFallback: false,
190
+ durationMs: 9,
191
+ });
192
+
193
+ const { ctx } = makeCtx();
194
+ const result = await execute(ctx);
195
+
196
+ expect(result.resultJson?.triage).toEqual({
197
+ toolsets: ["file"],
198
+ used_fallback: false,
199
+ error: null,
200
+ duration_ms: 9,
201
+ });
202
+ });
203
+
204
+ it("when triage returns an empty list, omits -t and surfaces fallback metadata", async () => {
205
+ triageToolsetsMock.mockResolvedValue({
206
+ toolsets: [],
207
+ usedFallback: true,
208
+ error: "triage call failed",
209
+ durationMs: 4,
210
+ });
211
+
212
+ const { ctx } = makeCtx();
213
+ const result = await execute(ctx);
214
+
215
+ expect(findToolsetsArg(getArgs())).toBeNull();
216
+ expect(result.resultJson?.triage?.used_fallback).toBe(true);
217
+ expect(result.resultJson?.triage?.error).toBe("triage call failed");
218
+ });
219
+ });
220
+
221
+ describe("execute: triage off when no toolsets and triageEnabled disabled", () => {
222
+ it("resultJson.triage is null when triage is skipped", async () => {
223
+ const { ctx } = makeCtx({
224
+ adapterConfig: { toolsets: "file" },
225
+ });
226
+ const result = await execute(ctx);
227
+ expect(result.resultJson?.triage).toBeNull();
228
+ });
229
+ });
230
+
231
+ describe("execute: headless I/O contract (FID-52)", () => {
232
+ // FideliOS spawns always set FIDELIOS_RUN_ID — vitest's process.env may or
233
+ // may not have it depending on parent invocation. We force the desired
234
+ // state per test via FIDELIOS_HEADLESS to avoid leaking between cases.
235
+ const ORIG_HEADLESS = process.env.FIDELIOS_HEADLESS;
236
+ const ORIG_RUN_ID = process.env.FIDELIOS_RUN_ID;
237
+
238
+ beforeEach(() => {
239
+ delete process.env.FIDELIOS_HEADLESS;
240
+ delete process.env.FIDELIOS_RUN_ID;
241
+ });
242
+
243
+ it("strips clarify from a triage selection when headless", async () => {
244
+ process.env.FIDELIOS_HEADLESS = "1";
245
+ triageToolsetsMock.mockResolvedValue({
246
+ toolsets: ["file", "clarify", "web"],
247
+ usedFallback: false,
248
+ durationMs: 7,
249
+ });
250
+
251
+ const { ctx, logs } = makeCtx();
252
+ const result = await execute(ctx);
253
+
254
+ expect(findToolsetsArg(getArgs())).toBe("file,web");
255
+ expect(result.resultJson?.headless).toEqual({
256
+ headless: true,
257
+ stripped: ["clarify"],
258
+ explicit_override: false,
259
+ });
260
+ const banner = logs.find((l) => l.chunk.includes("[hermes-headless] stripped"));
261
+ expect(banner).toBeTruthy();
262
+ expect(banner.chunk).toMatch(/clarify/);
263
+ });
264
+
265
+ it("strips clarify from an explicitly-pinned toolset whitelist when headless", async () => {
266
+ process.env.FIDELIOS_HEADLESS = "1";
267
+
268
+ const { ctx } = makeCtx({
269
+ adapterConfig: { toolsets: "clarify,file" },
270
+ });
271
+ const result = await execute(ctx);
272
+
273
+ expect(triageToolsetsMock).not.toHaveBeenCalled();
274
+ expect(findToolsetsArg(getArgs())).toBe("file");
275
+ expect(result.resultJson?.headless?.stripped).toEqual(["clarify"]);
276
+ expect(result.resultJson?.headless?.explicit_override).toBe(true);
277
+ });
278
+
279
+ it("omits -t entirely when stripping leaves nothing", async () => {
280
+ process.env.FIDELIOS_HEADLESS = "1";
281
+
282
+ const { ctx } = makeCtx({
283
+ adapterConfig: { toolsets: "clarify" },
284
+ });
285
+ await execute(ctx);
286
+
287
+ expect(findToolsetsArg(getArgs())).toBeNull();
288
+ });
289
+
290
+ it("does not strip clarify in interactive mode (no FideliOS markers, default TTY)", async () => {
291
+ // No FIDELIOS_HEADLESS, no FIDELIOS_RUN_ID. The detector falls back to
292
+ // the stdin TTY hint; in vitest stdin is typically not a TTY but we
293
+ // bypass that here by explicitly setting FIDELIOS_HEADLESS=0.
294
+ process.env.FIDELIOS_HEADLESS = "0";
295
+
296
+ const { ctx } = makeCtx({
297
+ adapterConfig: { toolsets: "clarify,file" },
298
+ });
299
+ const result = await execute(ctx);
300
+
301
+ expect(findToolsetsArg(getArgs())).toBe("clarify,file");
302
+ expect(result.resultJson?.headless).toBeNull();
303
+ });
304
+
305
+ it("resultJson.headless is null in interactive mode even with no toolsets", async () => {
306
+ process.env.FIDELIOS_HEADLESS = "0";
307
+
308
+ triageToolsetsMock.mockResolvedValue({
309
+ toolsets: ["file"],
310
+ usedFallback: false,
311
+ durationMs: 2,
312
+ });
313
+
314
+ const { ctx } = makeCtx();
315
+ const result = await execute(ctx);
316
+
317
+ expect(result.resultJson?.headless).toBeNull();
318
+
319
+ // Restore for subsequent suites
320
+ if (ORIG_HEADLESS !== undefined) process.env.FIDELIOS_HEADLESS = ORIG_HEADLESS;
321
+ else delete process.env.FIDELIOS_HEADLESS;
322
+ if (ORIG_RUN_ID !== undefined) process.env.FIDELIOS_RUN_ID = ORIG_RUN_ID;
323
+ });
324
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Headless I/O contract for hermes-local (FID-52).
3
+ *
4
+ * Hermes ships interactive toolsets — most notably `clarify` — that block on
5
+ * `stdin.read()` waiting for a human answer. When FideliOS spawns Hermes
6
+ * unattended, no human is at the terminal and the agent stalls until Hermes'
7
+ * own per-clarify timeout (120 s) elapses, multiplied by the number of
8
+ * clarify calls in a session. FID-47 surfaced this as the 600 s adapter
9
+ * timeout being hit.
10
+ *
11
+ * This module enforces a contract for headless runs:
12
+ * 1. Detect headless mode (FideliOS spawn vs. interactive `hermes chat`).
13
+ * 2. Strip toolsets flagged `headlessSafe: false` in the registry from the
14
+ * final `-t` whitelist, regardless of whether they were chosen by triage
15
+ * or pinned by the operator. Telemetry records what was stripped.
16
+ * 3. Provide a small escalation helper used by both the prompt template
17
+ * (which teaches the model to escalate via curl) and an optional
18
+ * adapter-side safety-net.
19
+ *
20
+ * Resume flow is handled by FideliOS: when the Board comments on the issue,
21
+ * the next heartbeat fires with `FIDELIOS_WAKE_COMMENT_ID` set, which the
22
+ * existing prompt template surfaces to the model.
23
+ */
24
+
25
+ import {
26
+ HERMES_TOOLSET_REGISTRY,
27
+ isHeadlessSafeToolset,
28
+ } from "./toolset-registry.js";
29
+
30
+ /**
31
+ * @typedef {Object} HeadlessFilterResult
32
+ * @property {string[]} kept Toolset names that survived the filter.
33
+ * @property {string[]} stripped Toolset names that were removed (unknown to
34
+ * caller as either unsafe or unrecognised).
35
+ */
36
+
37
+ /**
38
+ * @typedef {Object} HeadlessTelemetry
39
+ * @property {boolean} headless True when running unattended.
40
+ * @property {string[]} stripped Toolsets removed because flagged
41
+ * `headlessSafe: false` in the registry.
42
+ * @property {boolean} explicitOverride True when the operator pinned toolsets
43
+ * and the strip removed at least one.
44
+ */
45
+
46
+ /**
47
+ * Heuristic: are we running unattended?
48
+ *
49
+ * FideliOS spawns always inject `FIDELIOS_RUN_ID`. An explicit override
50
+ * (`FIDELIOS_HEADLESS=1`) is also honoured for tests / SaaS deployments where
51
+ * the spawn is wrapped further. Otherwise fall back to TTY detection.
52
+ *
53
+ * @param {Record<string, string|undefined>} env
54
+ * @param {{stdinIsTTY?: boolean}} [opts]
55
+ * @returns {boolean}
56
+ */
57
+ export function isHeadlessEnv(env, opts = {}) {
58
+ if (env && env.FIDELIOS_HEADLESS === "0") return false;
59
+ if (env && (env.FIDELIOS_HEADLESS === "1" || env.FIDELIOS_HEADLESS === "true")) return true;
60
+ if (env && typeof env.FIDELIOS_RUN_ID === "string" && env.FIDELIOS_RUN_ID.length > 0) return true;
61
+ // Fall back to stdin TTY hint if provided. Default: assume interactive
62
+ // when no FideliOS markers are present so local `hermes chat` keeps working.
63
+ if (typeof opts.stdinIsTTY === "boolean") return !opts.stdinIsTTY;
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Filter a toolset list, dropping anything flagged `headlessSafe: false` in
69
+ * the canonical registry. Unknown names pass through untouched (the triage
70
+ * filter and Hermes itself will flag them).
71
+ *
72
+ * @param {string[]} toolsets
73
+ * @param {Array<{name: string, headlessSafe?: boolean}>} [registry]
74
+ * @returns {HeadlessFilterResult}
75
+ */
76
+ export function filterHeadlessUnsafe(toolsets, registry) {
77
+ const kept = [];
78
+ const stripped = [];
79
+ const reg = registry ?? HERMES_TOOLSET_REGISTRY;
80
+ const byName = new Map(reg.map((e) => [e.name, e]));
81
+ for (const name of toolsets) {
82
+ const entry = byName.get(name);
83
+ if (entry && entry.headlessSafe === false) {
84
+ stripped.push(name);
85
+ } else {
86
+ kept.push(name);
87
+ }
88
+ }
89
+ return { kept, stripped };
90
+ }
91
+
92
+ /**
93
+ * Convenience: parse a comma-separated `-t` value, run the filter, and return
94
+ * both the new comma-string and the stripped list. Empty input → empty output.
95
+ *
96
+ * @param {string|undefined} csv
97
+ * @param {Array<{name: string, headlessSafe?: boolean}>} [registry]
98
+ * @returns {{csv: string|undefined, stripped: string[]}}
99
+ */
100
+ export function filterHeadlessCsv(csv, registry) {
101
+ if (!csv || typeof csv !== "string") return { csv, stripped: [] };
102
+ const names = csv.split(",").map((s) => s.trim()).filter(Boolean);
103
+ if (names.length === 0) return { csv, stripped: [] };
104
+ const result = filterHeadlessUnsafe(names, registry);
105
+ const newCsv = result.kept.length > 0 ? result.kept.join(",") : "";
106
+ return { csv: newCsv, stripped: result.stripped };
107
+ }
108
+
109
+ /**
110
+ * Escalation helper: post a comment on the FideliOS issue and PATCH it to
111
+ * `blocked` so the Board sees the question and can answer.
112
+ *
113
+ * Designed to be safe to call from the prompt-template guidance path
114
+ * (model-driven escalation) AND from a future adapter-level intercept.
115
+ *
116
+ * @param {Object} args
117
+ * @param {string} args.question The clarification question to escalate.
118
+ * @param {string} args.taskId The FideliOS issue ID.
119
+ * @param {string} args.apiUrl FideliOS API base, e.g. http://127.0.0.1:3100/api.
120
+ * @param {string} [args.apiKey] Optional bearer token.
121
+ * @param {typeof fetch} [args.fetchImpl]
122
+ * @returns {Promise<{ok: boolean, commentId?: string, error?: string}>}
123
+ */
124
+ export async function escalateClarify(args) {
125
+ const fetchImpl = args.fetchImpl ?? globalThis.fetch;
126
+ if (typeof fetchImpl !== "function") {
127
+ return { ok: false, error: "fetch not available" };
128
+ }
129
+ if (!args.taskId) return { ok: false, error: "taskId required" };
130
+ if (!args.apiUrl) return { ok: false, error: "apiUrl required" };
131
+
132
+ const headers = { "Content-Type": "application/json" };
133
+ if (args.apiKey) headers["Authorization"] = `Bearer ${args.apiKey}`;
134
+ const body = `❓ Agent question (escalated, headless): ${args.question}`;
135
+
136
+ try {
137
+ const commentRes = await fetchImpl(
138
+ `${args.apiUrl.replace(/\/+$/, "")}/issues/${args.taskId}/comments`,
139
+ { method: "POST", headers, body: JSON.stringify({ body }) },
140
+ );
141
+ if (!commentRes.ok) {
142
+ return { ok: false, error: `comment POST ${commentRes.status}` };
143
+ }
144
+ const commentJson = await commentRes.json().catch(() => ({}));
145
+ const commentId = commentJson?.id;
146
+
147
+ const patchRes = await fetchImpl(
148
+ `${args.apiUrl.replace(/\/+$/, "")}/issues/${args.taskId}`,
149
+ { method: "PATCH", headers, body: JSON.stringify({ status: "blocked" }) },
150
+ );
151
+ if (!patchRes.ok) {
152
+ return { ok: false, error: `status PATCH ${patchRes.status}`, commentId };
153
+ }
154
+ return { ok: true, commentId };
155
+ } catch (err) {
156
+ return { ok: false, error: err && err.message ? err.message : String(err) };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Hermes prints clarify questions to its TUI; in `-q` quiet mode it is not
162
+ * trivial to extract them. This helper detects the marker line emitted by
163
+ * Hermes' verbose log when entering clarify, so a future safety-net watcher
164
+ * can pull the question text. Exposed for test ergonomics.
165
+ *
166
+ * Marker format (Hermes v0.12.0): `[tool] clarify { "question": "...", ... }`
167
+ * or the `❓ Clarifying Questions` banner.
168
+ *
169
+ * @param {string} line
170
+ * @returns {string|null} The question text if the line marks clarify entry.
171
+ */
172
+ export function parseClarifyMarker(line) {
173
+ if (!line || typeof line !== "string") return null;
174
+ // Verbose tool-call line: [tool] clarify {"question":"..."}
175
+ const toolMatch = line.match(/\[tool\]\s+clarify\s+(\{.*\})/);
176
+ if (toolMatch) {
177
+ try {
178
+ const obj = JSON.parse(toolMatch[1]);
179
+ if (obj && typeof obj.question === "string") return obj.question;
180
+ } catch {
181
+ // Fall through to other markers.
182
+ }
183
+ }
184
+ return null;
185
+ }