From 6fb6d0dc276d83d70dcfcddbe9dd23dc80b5bec5 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 22 Sep 2026 16:21:19 -0700 Subject: [PATCH 1/4] /acp-status prints suggestions from your own usage, each with its command (0.27.0) The gateway's status response now carries `advice`: the top items from the merged policy + cost feed (gatewaystack-connect#1325). The status command prints up to three under "Suggestions from your usage", each followed by the command that acts on it. Gateway text is stripped of control characters and capped. The section is omitted when the field is absent, so an older gateway prints exactly what it did before. Version 0.27.0: the hook file changed, and attestation is trust-on-first- use per (client, version). --- .claude-plugin/marketplace.json | 2 +- bin/govern.mjs | 14 ++++++++++++-- plugin.json | 2 +- test/terminal-intents.test.mjs | 24 ++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5658f72..3014c37 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "agentic-control-plane", "source": "./", "description": "Control, audit, and cost-optimize every Claude Code tool call. Governance hook + bundled ACP MCP (cost X-ray, run traces, policy checks) + /cost-xray pre-ship report.", - "version": "0.26.0", + "version": "0.27.0", "author": { "name": "GatewayStack" }, diff --git a/bin/govern.mjs b/bin/govern.mjs index d53f389..12a9efd 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -67,7 +67,7 @@ const ACP_GOVERN = process.env.ACP_API_BASE || "https://govern.agenticcontrolplane.com"; -const PLUGIN_VERSION = "0.26.0"; +const PLUGIN_VERSION = "0.27.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -2036,10 +2036,20 @@ async function handleUserPromptExpansion() { const asks = Array.isArray(s.pendingApprovals) && s.pendingApprovals.length ? `\nPending approvals: ${s.pendingApprovals.length} (${ACP_CONSOLE}/approvals)` : ""; + // Advice feed (gatewaystack-connect#1325): the top suggestions from + // your own usage, each with the command that acts on it. Absent when + // the workspace isn't rolled out or the feed wasn't ready in time. + const clean = (v) => String(v ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 300); + const advice = Array.isArray(s.advice) && s.advice.length + ? "\nSuggestions from your usage:\n" + s.advice.slice(0, 3).map((a) => { + const cmd = a && typeof a.command === "string" && a.command ? `\n → ${clean(a.command)}` : ""; + return ` • ${clean(a?.line)}${cmd}`; + }).join("\n") + : ""; const next = s.mode === "enforce" ? "/acp-allow stops the asking for one tool; /acp-audit records only." : "/acp-enforce turns the starter rules on so they ask first."; - blockExpansion(`[ACP] ${s.workspace} is in ${s.mode} mode.\nInteractive rules:\n${rules}${proposals}${asks}\n${next}`); + blockExpansion(`[ACP] ${s.workspace} is in ${s.mode} mode.\nInteractive rules:\n${rules}${proposals}${asks}${advice}\n${next}`); } const res = await fetch(`${ACP_API}/plugin/intents`, { diff --git a/plugin.json b/plugin.json index 8fc853b..6e49417 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.26.0", + "version": "0.27.0", "description": "Identity, governance, and audit for every Claude Code tool call. Logs all tool usage, enforces policies, and gives teams full visibility \u2014 without changing how you use Claude.", "author": { "name": "GatewayStack", diff --git a/test/terminal-intents.test.mjs b/test/terminal-intents.test.mjs index 8e44710..830a478 100644 --- a/test/terminal-intents.test.mjs +++ b/test/terminal-intents.test.mjs @@ -196,6 +196,30 @@ test("(c) /acp-status reads GET /plugin/intents/status and lists the mode + a pr assert.ok(out.reason.includes("/acp-apply p1"), "reason should include the apply hint for proposal p1"); }); +test("(c2) /acp-status prints the advice feed with each item's command, and omits the section when absent", async () => { + nextStatusResponse = { + ok: true, + workspace: "acme", + mode: "enforce", + rules: [], + proposals: [], + pendingApprovals: [], + advice: [ + { id: "policy:auto_allow:Bash.git-push", line: "You approved Bash.git-push 6 times this month and never said no.", command: "/acp-allow Bash.git-push", commandKind: "acp", impactUsd: null }, + { id: "cost:cache_economics:x", line: "x pays full price for most of its input.\u001b[31m", command: null, commandKind: null, impactUsd: 90 }, + ], + }; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + assert.ok(out.reason.includes("Suggestions from your usage:")); + assert.ok(out.reason.includes("→ /acp-allow Bash.git-push")); + assert.ok(out.reason.includes("pays full price")); + assert.ok(!out.reason.includes("\u001b"), "control characters from the gateway are stripped"); + + nextStatusResponse = { ok: true, workspace: "acme", mode: "audit", rules: [], proposals: [], pendingApprovals: [] }; + const plain = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + assert.ok(!plain.reason.includes("Suggestions")); +}); + test("(d) a non-ACP command never triggers a request or any output", async () => { const out = await runHook(expansion({ command_name: "probe:enforce" })); assert.equal(out, null); From 768dcc984e99a00b0c505333215a86a7fdc1aafb Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 22 Sep 2026 16:38:12 -0700 Subject: [PATCH 2/4] Held calls in the terminal: wait steer, /acp-why, approval links in /acp-status (0.27.0) - When the gateway says an admin decides a hold and names acp_wait_approval (gatewaystack-connect#1326), the hook's steer tells the agent to wait with that tool, retry once on approved and stop on denied or timeout, instead of the generic "hand it over" line. - /acp-why (gatewaystack-connect#1327): explains the last call ACP held in this session and the command that would change it. Read from a local file the hook writes on every deny or ask; no network, no key. Kept apart from the receipt counters, which Stop clears every turn. - /acp-status lists each pending approval with its own link (https only) instead of a count. - /acp-apply also takes context-guard (the gateway maps it to the lever). --- README.md | 2 +- bin/govern.mjs | 70 ++++++++++++++++++++++++++++++++-- commands/acp-apply.md | 2 +- commands/acp-why.md | 9 +++++ test/terminal-intents.test.mjs | 62 ++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 commands/acp-why.md diff --git a/README.md b/README.md index 43f4af6..df0df90 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Not every step_up has to end at a console link. When a workspace rule says ask a ### Commands -Type `/acp-status`, `/acp-enforce`, `/acp-audit`, `/acp-allow `, `/acp-ask `, `/acp-deny `, or `/acp-apply ` in the terminal to check or change how your workspace is governed without leaving the session. The hook files exactly what you typed and prints a link back — nothing changes until you open it and tap Confirm, so a stray or injected command can't move policy on its own. A confirmed change applies to the whole workspace, the same as making it in the console; if you're not an admin, your request is filed for one to review. +Type `/acp-status`, `/acp-enforce`, `/acp-audit`, `/acp-allow `, `/acp-ask `, `/acp-deny `, `/acp-apply ` or `/acp-apply context-guard` in the terminal to check or change how your workspace is governed without leaving the session. `/acp-status` also lists suggestions from your own usage and every approval waiting on someone, each with its link. `/acp-why` explains the last call ACP held in this session, locally, with no network call. The hook files exactly what you typed and prints a link back — nothing changes until you open it and tap Confirm, so a stray or injected command can't move policy on its own. A confirmed change applies to the whole workspace, the same as making it in the console; if you're not an admin, your request is filed for one to review. ### Context guard (v0.15.0+, off by default) diff --git a/bin/govern.mjs b/bin/govern.mjs index 12a9efd..ecfe9c8 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -1147,8 +1147,14 @@ async function handlePreToolUse() { reformulate: "This refused ONE operation, not your task — continue with everything else. If you believe you should have this capability, call acp_propose_rule (tool, tier, rationale) to draft a rule for a human to approve; it is never applied by you.", }; + // A workspace admin decides this hold, and the gateway told the agent how + // to wait for the answer (gatewaystack-connect#1326). The generic + // "hand it over" steer would contradict that, so this one wins. + const WAIT_STEER = + "A workspace admin has to decide this call. Do what the reason says: call acp_wait_approval with that approval_id, retry the identical call only after it reports approved, and stop if it reports denied or timeout. Keep working on anything that does not need this call while you wait."; function denyByPolicy(reason, kind) { - const steer = STEER_BY_KIND[kind] + rememberHeld(reason, kind); + const steer = /\bacp_wait_approval\b/.test(reason) ? WAIT_STEER : STEER_BY_KIND[kind] || (UNPROPOSABLE.test(reason) ? STEER_BY_KIND.terminal : STEER_BY_KIND.reformulate); process.stdout.write(JSON.stringify({ hookSpecificOutput: { @@ -1263,6 +1269,7 @@ async function handlePreToolUse() { failPostureOnOutage(detail || "network error"); } function ask(reason) { + rememberHeld(reason, "ask"); // Codex has no ask semantic on the wire (see HARNESS note): emit deny // with the approval deep link so the human approves out-of-band and // the re-run passes under the grant. @@ -1941,6 +1948,56 @@ function bumpReceiptStats(sessionId, delta) { } catch { /* bookkeeping must never touch the call path */ } } +// Last held call, for /acp-why (gatewaystack-connect#1327). Kept beside the +// receipt counters but in its own file: the Stop hook clears the counters +// every turn, and "why was that blocked?" is usually asked a turn later. +// Local only, never sent anywhere; pruned with the rest after 7 days. +function heldPath(sessionId) { + const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128); + return join(SESSION_STATS_DIR, `${safe}.held.json`); +} + +function rememberHeld(reason, kind) { + const sid = input.session_id; + if (!sid || sid === "unknown") return; + try { + mkdirSync(SESSION_STATS_DIR, { recursive: true }); + writeFileSync(heldPath(sid), JSON.stringify({ + tool: typeof input.tool_name === "string" ? input.tool_name.slice(0, 80) : "", + reason: String(reason ?? "").slice(0, 2000), + kind: typeof kind === "string" ? kind : null, + at: new Date().toISOString(), + })); + } catch { /* bookkeeping must never touch the call path */ } +} + +function readHeld(sessionId) { + try { return JSON.parse(readFileSync(heldPath(sessionId), "utf8")); } catch { return null; } +} + +/** The lines /acp-why prints: what was held, why, and what changes it. */ +function explainHeld(held) { + if (!held || !held.reason) { + return "[ACP] Nothing has been held in this session."; + } + const clean = (v) => String(v ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, " "); + const lines = [`[ACP] Last held call${held.tool ? ` (${clean(held.tool)})` : ""} at ${clean(held.at)}:`, ` ${clean(held.reason)}`]; + // Gateway wording: "denied by policy for " and + // "step_up by policy for "; the label can hold spaces + // and parentheses ("api tier (scoped API key)"). + const rule = /\b(?:denied|step_up|defer) by [^\n]{1,80}? policy for ([A-Za-z][A-Za-z0-9_.:-]{0,79})/.exec(held.reason); + const review = /Review: (https:\/\/\S+)/.exec(held.reason); + if (review) lines.push(`Approval: ${review[1]}`); + if (rule) { + lines.push(`A workspace rule on ${rule[1]} held it. /acp-allow ${rule[1]} stops holding it; /acp-status lists every rule.`); + } else if (/^\s*(hardline floor|governance surface|uninstall floor|destructive floor)\b/i.test(held.reason) || held.kind === "terminal") { + lines.push("This is a safety floor, not a workspace rule; no command loosens it."); + } else { + lines.push("/acp-status shows the workspace mode and rules."); + } + return lines.join("\n"); +} + function clearReceiptStats(sessionId) { try { unlinkSync(receiptStatsPath(sessionId)); } catch { /* absent is fine */ } try { @@ -1998,7 +2055,7 @@ function handleStop() { // writes to the transcript. That is fine by design: the link executes only // the intent the human already typed, as that human, once, inside its TTL // — nothing a reader of the transcript can redirect. -const INTENT_COMMAND_RE = /(?:^|:)acp-(enforce|audit|allow|ask|deny|apply|status)$/; +const INTENT_COMMAND_RE = /(?:^|:)acp-(enforce|audit|allow|ask|deny|apply|status|why)$/; function blockExpansion(reason) { process.stdout.write(JSON.stringify({ decision: "block", reason })); @@ -2014,6 +2071,11 @@ async function handleUserPromptExpansion() { const kind = m[1]; const target = typeof input.command_args === "string" ? input.command_args.trim().split(/\s+/)[0] || "" : ""; + // /acp-why: local, read-only, no network and no key needed. + if (kind === "why") { + blockExpansion(explainHeld(readHeld(input.session_id ?? "unknown"))); + } + if (!token) { blockExpansion(`[ACP] Not connected — /acp-${kind} needs a workspace key. Run /acp-connect first.`); } @@ -2033,8 +2095,10 @@ async function handleUserPromptExpansion() { const proposals = Array.isArray(s.proposals) && s.proposals.length ? "\nProposals waiting for you:\n" + s.proposals.map((p) => ` ${p.id} ${p.tool} → ${p.permission === "step_up" ? "ask" : p.permission} (${p.source ?? "agent"}) /acp-apply ${p.id}`).join("\n") : ""; + const link = (v) => (typeof v === "string" && /^https:\/\/\S+$/.test(v) ? v : ""); const asks = Array.isArray(s.pendingApprovals) && s.pendingApprovals.length - ? `\nPending approvals: ${s.pendingApprovals.length} (${ACP_CONSOLE}/approvals)` + ? `\nPending approvals (${s.pendingApprovals.length}):\n` + s.pendingApprovals.slice(0, 10).map((a) => + ` ${String(a?.tool ?? "a tool call").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 80)} ${link(a?.review) || `${ACP_CONSOLE}/approvals`}`).join("\n") : ""; // Advice feed (gatewaystack-connect#1325): the top suggestions from // your own usage, each with the command that acts on it. Absent when diff --git a/commands/acp-apply.md b/commands/acp-apply.md index 54003fd..5890d1c 100644 --- a/commands/acp-apply.md +++ b/commands/acp-apply.md @@ -1,6 +1,6 @@ --- name: acp-apply -description: "Confirm a rule an agent proposed: /acp-apply . You confirm with one tap." +description: "Confirm a proposed rule (/acp-apply ) or switch on a cost lever (/acp-apply context-guard). You confirm with one tap." user-invocable: true --- diff --git a/commands/acp-why.md b/commands/acp-why.md new file mode 100644 index 0000000..c5a7f32 --- /dev/null +++ b/commands/acp-why.md @@ -0,0 +1,9 @@ +--- +name: acp-why +description: Explain the last tool call ACP held in this session, and the command that would change it. +user-invocable: true +--- + +This command is handled by the ACP hook before it reaches you: the hook prints the explanation for the human. If you are reading this, the hook did not intercept it — this Claude Code is older than the UserPromptExpansion hook event. + +Tell the user, in one line: "Your Claude Code is too old for terminal commands — update it, or see this session in the ACP console." diff --git a/test/terminal-intents.test.mjs b/test/terminal-intents.test.mjs index 830a478..e6dd99e 100644 --- a/test/terminal-intents.test.mjs +++ b/test/terminal-intents.test.mjs @@ -265,3 +265,65 @@ test("(h) unreachable stub → blocked mentioning ACP was unreachable", async () assert.equal(out.decision, "block"); assert.match(out.reason, /Couldn't reach ACP/); }); + +// ── #1326 / #1327: held calls, /acp-why, approvals in /acp-status ────── + +const HELD_REASON = + 'approval-requested:0b3c7a52-1111-4000-8000-000000000000 — approvers notified; action blocked until approved. ' + + "Review: https://cloud.agenticcontrolplane.com/approvals?id=0b3c7a52-1111-4000-8000-000000000000 " + + 'A workspace admin decides this one. To keep going, call the acp_wait_approval tool with approval_id "0b3c7a52-1111-4000-8000-000000000000".'; + +const preToolUse = (overrides = {}) => ({ + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: "/tmp/acp-why-probe.txt" }, + session_id: "sess-why-1", + permission_mode: "default", + ...overrides, +}); + +test("(g) an admin-decided hold tells the agent to wait, not to hand the step over", async () => { + nextResponse = { decision: "deny", reason: HELD_REASON, kind: "delegate" }; + const out = await runHook(preToolUse()); + const reason = out?.hookSpecificOutput?.permissionDecisionReason ?? ""; + assert.equal(out?.hookSpecificOutput?.permissionDecision, "deny"); + assert.match(reason, /call acp_wait_approval with that approval_id/); + assert.ok(!reason.includes("Hand that one step over"), "the generic delegate steer must not contradict the wait"); +}); + +test("(h) /acp-why explains the last held call from local state, with no request", async () => { + nextResponse = { decision: "deny", reason: "denied by interactive tier policy for Bash.rm", kind: "reformulate" }; + await runHook(preToolUse({ session_id: "sess-why-2", tool_name: "Bash", tool_input: { command: "ls" } })); + seen = []; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-why", session_id: "sess-why-2" })); + assert.equal(seen.length, 0, "/acp-why makes no network call"); + assert.equal(out.decision, "block"); + assert.match(out.reason, /Last held call \(Bash\)/); + assert.match(out.reason, /\/acp-allow Bash\.rm stops holding it/); +}); + +test("(i) /acp-why with an approval hold prints the approval link", async () => { + nextResponse = { decision: "deny", reason: HELD_REASON, kind: "delegate" }; + await runHook(preToolUse({ session_id: "sess-why-3" })); + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-why", session_id: "sess-why-3" })); + assert.match(out.reason, /Approval: https:\/\/cloud\.agenticcontrolplane\.com\/approvals\?id=0b3c7a52/); +}); + +test("(j) /acp-why with nothing held says so", async () => { + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-why", session_id: "sess-never-held" })); + assert.match(out.reason, /Nothing has been held in this session/); +}); + +test("(k) /acp-status lists each pending approval with its own link", async () => { + nextStatusResponse = { + ok: true, workspace: "acme", mode: "enforce", rules: [], proposals: [], + pendingApprovals: [ + { id: "a1", tool: "Bash.git-push", channel: "console", review: "https://cloud.agenticcontrolplane.com/approvals?id=a1" }, + { id: "a2", tool: "Bash.rm", channel: "console", review: "javascript:alert(1)" }, + ], + }; + const out = await runHook(expansion({ command_name: "agentic-control-plane:acp-status", command_args: "" })); + assert.match(out.reason, /Pending approvals \(2\):/); + assert.match(out.reason, /Bash\.git-push {2}https:\/\/cloud\.agenticcontrolplane\.com\/approvals\?id=a1/); + assert.ok(!out.reason.includes("javascript:"), "only https links are printed"); +}); From 7edd7db68c10a02a58c3d41ffdbf1d918b94ddb4 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 22 Sep 2026 16:49:14 -0700 Subject: [PATCH 3/4] Wait steer: name where the decision happens, not who Matches the gateway's wait hint. On Codex the owner is often the one approving in the console. --- bin/govern.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/govern.mjs b/bin/govern.mjs index ecfe9c8..b8f119e 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -1147,11 +1147,11 @@ async function handlePreToolUse() { reformulate: "This refused ONE operation, not your task — continue with everything else. If you believe you should have this capability, call acp_propose_rule (tool, tier, rationale) to draft a rule for a human to approve; it is never applied by you.", }; - // A workspace admin decides this hold, and the gateway told the agent how + // A human decides this hold outside the terminal, and the gateway told the agent how // to wait for the answer (gatewaystack-connect#1326). The generic // "hand it over" steer would contradict that, so this one wins. const WAIT_STEER = - "A workspace admin has to decide this call. Do what the reason says: call acp_wait_approval with that approval_id, retry the identical call only after it reports approved, and stop if it reports denied or timeout. Keep working on anything that does not need this call while you wait."; + "This call is decided in the ACP console or from the approval email. Do what the reason says: call acp_wait_approval with that approval_id, retry the identical call only after it reports approved, and stop if it reports denied or timeout. Keep working on anything that does not need this call while you wait."; function denyByPolicy(reason, kind) { rememberHeld(reason, kind); const steer = /\bacp_wait_approval\b/.test(reason) ? WAIT_STEER : STEER_BY_KIND[kind] From ccd818870966c814c882c4111f1589df3569439e Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 22 Sep 2026 17:16:27 -0700 Subject: [PATCH 4/4] Strip C1 and bidi-override characters from gateway text (0.27.0) The /acp-status, /acp-why and approval-list filters removed C0 controls and ESC but not C1 (U+0080-U+009F) or bidi overrides, which can reorder what a terminal shows. Found in review. --- bin/govern.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/govern.mjs b/bin/govern.mjs index b8f119e..e01008b 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -1980,7 +1980,7 @@ function explainHeld(held) { if (!held || !held.reason) { return "[ACP] Nothing has been held in this session."; } - const clean = (v) => String(v ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, " "); + const clean = (v) => String(v ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " "); const lines = [`[ACP] Last held call${held.tool ? ` (${clean(held.tool)})` : ""} at ${clean(held.at)}:`, ` ${clean(held.reason)}`]; // Gateway wording: "denied by policy for " and // "step_up by policy for "; the label can hold spaces @@ -2098,12 +2098,12 @@ async function handleUserPromptExpansion() { const link = (v) => (typeof v === "string" && /^https:\/\/\S+$/.test(v) ? v : ""); const asks = Array.isArray(s.pendingApprovals) && s.pendingApprovals.length ? `\nPending approvals (${s.pendingApprovals.length}):\n` + s.pendingApprovals.slice(0, 10).map((a) => - ` ${String(a?.tool ?? "a tool call").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 80)} ${link(a?.review) || `${ACP_CONSOLE}/approvals`}`).join("\n") + ` ${String(a?.tool ?? "a tool call").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 80)} ${link(a?.review) || `${ACP_CONSOLE}/approvals`}`).join("\n") : ""; // Advice feed (gatewaystack-connect#1325): the top suggestions from // your own usage, each with the command that acts on it. Absent when // the workspace isn't rolled out or the feed wasn't ready in time. - const clean = (v) => String(v ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 300); + const clean = (v) => String(v ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 300); const advice = Array.isArray(s.advice) && s.advice.length ? "\nSuggestions from your usage:\n" + s.advice.slice(0, 3).map((a) => { const cmd = a && typeof a.command === "string" && a.command ? `\n → ${clean(a.command)}` : "";