ruvnet-brain 3.9.133-dev → 4.0.1
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/.claude-plugin/marketplace.json +13 -0
- package/README.md +3 -3
- package/bin/install.mjs +284 -33
- package/kb/zip-extract.mjs +53 -14
- package/package.json +7 -1
- package/plugin/.claude-plugin/marketplace.json +13 -0
- package/plugin/.claude-plugin/plugin.json +23 -0
- package/plugin/.codex-plugin/plugin.json +21 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/brain-console.md +16 -0
- package/plugin/commands/configure.md +32 -0
- package/plugin/commands/rvbc.md +78 -0
- package/plugin/commands/rvcb.md +16 -0
- package/plugin/commands/whats-new.md +57 -0
- package/plugin/hooks/codex-hooks.json +160 -0
- package/plugin/hooks/hook-contracts.json +77 -0
- package/plugin/hooks/hooks.json +203 -0
- package/plugin/mcp/server.mjs +35 -6
- package/plugin/scripts/anticipate.sh +534 -0
- package/plugin/scripts/codex-hook-adapter.mjs +96 -0
- package/plugin/scripts/continuation-gate.mjs +267 -0
- package/plugin/scripts/design-wall.sh +137 -0
- package/plugin/scripts/detach.mjs +168 -0
- package/plugin/scripts/finalize-token-meter.mjs +25 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +507 -0
- package/plugin/scripts/grounding-stamp.sh +113 -0
- package/plugin/scripts/grounding-substance.mjs +595 -0
- package/plugin/scripts/hijack-ruvnet.sh +81 -0
- package/plugin/scripts/hook-input.mjs +558 -0
- package/plugin/scripts/hook-shim-bash.mjs +55 -0
- package/plugin/scripts/hook-shim.mjs +303 -0
- package/plugin/scripts/host-update.mjs +58 -0
- package/plugin/scripts/kling-preflight.sh +146 -0
- package/plugin/scripts/learn-capture.sh +154 -0
- package/plugin/scripts/learn-flush.mjs +138 -0
- package/plugin/scripts/lesson-hooks.sh +213 -0
- package/plugin/scripts/md-stamp.mjs +219 -0
- package/plugin/scripts/protect-brain-state.sh +84 -0
- package/plugin/scripts/route-dispatch.sh +147 -0
- package/plugin/scripts/routing-outcome-capture.mjs +89 -0
- package/plugin/scripts/session-start.sh +868 -0
- package/plugin/scripts/signal-watch.mjs +193 -0
- package/plugin/scripts/unprompted-runtime.mjs +377 -0
- package/plugin/scripts/update-apply.mjs +419 -0
- package/plugin/scripts/verify-interface.sh +53 -0
- package/plugin/scripts/version-bump-gate.sh +112 -0
- package/plugin/skills/brain-build/SKILL.md +123 -0
- package/plugin/skills/brain-console/SKILL.md +20 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +117 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +20 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- package/plugin/skills/whats-new/SKILL.md +22 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plugin/scripts/signal-watch.mjs — PostToolUse (matcher ^Bash$, anchored — ADR-058 §D3). The
|
|
3
|
+
// OBSERVED half ("W1") of the external-signal watch plane (DDD-0013 Context 2, aggregate SignalDebt).
|
|
4
|
+
//
|
|
5
|
+
// THE FAILURE THIS CLOSES: on 2026-07-27 GitHub CI was failing and the OWNER had to tell the model.
|
|
6
|
+
// A product that pitches "proactive" and needs a human to report a red pipeline has failed. This
|
|
7
|
+
// hook is the free half: the model already runs gh/vercel/netlify/npm publish/git push, so this
|
|
8
|
+
// classifies the command it already ran (no new polling, no new cost, no new tokens) and:
|
|
9
|
+
// - a SUCCESSFUL `git push` opens a pending CI-verdict debt keyed (repo, sha) — appended to
|
|
10
|
+
// pending.jsonl. scripts/signal-watch.mjs (the W2 poller — a SEPARATE process, SEPARATE file,
|
|
11
|
+
// SEPARATE cadence) resolves it later via `gh run list`; plugin/scripts/session-start.sh
|
|
12
|
+
// surfaces the transition. This is 2026-07-27 replayed with the human removed.
|
|
13
|
+
// - a FAILING gh/vercel/netlify/npm-publish invocation also appends a debt and prints one
|
|
14
|
+
// advisory line (additionalContext — never blocking; PostToolUse cannot block anyway).
|
|
15
|
+
//
|
|
16
|
+
// CLASSIFICATION IS STRUCTURAL, NEVER A GREP (ADR-058; hook-input.mjs's own header; issues
|
|
17
|
+
// #12/#13/#41/#44): findInvocations() below is hook-input.mjs's shared classifier, reused rather
|
|
18
|
+
// than reimplemented — it works in EXECUTABLE POSITION only, so the word "vercel" inside a commit
|
|
19
|
+
// message or a heredoc body can never fire this (the #12 lesson, generalized).
|
|
20
|
+
//
|
|
21
|
+
// ── VERIFY-FIRST CLAUSE (mandatory, ADR-058 §D3) — read before touching tool_response parsing ────
|
|
22
|
+
//
|
|
23
|
+
// The real Bash PostToolUse tool_response shape was captured from THREE live envelopes on
|
|
24
|
+
// 2026-07-28, BEFORE this parsing code was written (see
|
|
25
|
+
// tests/fixtures/signal-watch/bash-posttooluse-envelopes.json for the captures, their session IDs,
|
|
26
|
+
// and full provenance). Confirmed real shape: `{ stdout, stderr, interrupted, isImage,
|
|
27
|
+
// noOutputExpected }`. No exit-code field was ever observed in any of the three captures.
|
|
28
|
+
//
|
|
29
|
+
// That same investigation surfaced a SECOND, more consequential fact, reproduced FIVE separate
|
|
30
|
+
// times (a plain `false`, `false` run mid-sequence between two successful calls, a compound
|
|
31
|
+
// stderr+exit-1 command, and `ls` on a nonexistent path — twice, the second time with an
|
|
32
|
+
// invocation marker written to disk BEFORE the hook process even attempted to read stdin): a Bash
|
|
33
|
+
// tool call that itself exits non-zero never invoked this hook's process AT ALL, when driven via
|
|
34
|
+
// `claude -p` (the sdk-cli entrypoint), Claude Code v2.1.220. A parallel PreToolUse debug hook fired
|
|
35
|
+
// reliably on the byte-identical failing command in the same run, which isolates the gap to
|
|
36
|
+
// PostToolUse-after-a-Bash-error specifically, rather than a general hook-loading problem. This is
|
|
37
|
+
// reported as a MEASUREMENT (Rule 22/23), not a verdict: it may be an sdk-cli/print-mode-specific
|
|
38
|
+
// behavior rather than true interactive-session behavior, and is not asserted to hold universally.
|
|
39
|
+
//
|
|
40
|
+
// CONSEQUENCE FOR THIS DESIGN: the git-push SUCCESS path this entire feature depends on is
|
|
41
|
+
// unaffected — every one of the three real captures was a zero-exit Bash call, and every one fired
|
|
42
|
+
// this hook reliably. The "detect a failing gh/vercel/netlify/npm-publish command directly" behavior
|
|
43
|
+
// below is therefore built defensively (cliOutcome() below never assumes a field that was not
|
|
44
|
+
// actually observed) and stays ADVISORY ONLY, matching the ADR's own framing ("advisory, never
|
|
45
|
+
// blocking — malfunction is never a decision"). The AUTHORITATIVE verdict for any CI-shaped question
|
|
46
|
+
// always comes from scripts/signal-watch.mjs's poller (a real `gh run list`, resolved independently
|
|
47
|
+
// of whether this hook fired at all) — never from this observer's best-effort guess.
|
|
48
|
+
//
|
|
49
|
+
// SINGLE WRITER (DDD-0013 Context 2 invariant 3): this file is the ONLY writer of pending.jsonl.
|
|
50
|
+
// scripts/signal-watch.mjs (the poller) only ever reads it.
|
|
51
|
+
//
|
|
52
|
+
// CONTRACT (matches every other node hook body in this dir — md-stamp.mjs, learn-flush.mjs):
|
|
53
|
+
// dispatched via hook-shim.mjs (mode: advisory). ALWAYS exits 0; never throws to the caller; a
|
|
54
|
+
// parse/IO failure degrades to silence, never a crash the harness has to survive (DDD invariant 4:
|
|
55
|
+
// "observation never blocks and never manufactures a refusal").
|
|
56
|
+
|
|
57
|
+
import fs from 'node:fs';
|
|
58
|
+
import path from 'node:path';
|
|
59
|
+
import os from 'node:os';
|
|
60
|
+
import { spawnSync } from 'node:child_process';
|
|
61
|
+
import { fileURLToPath } from 'node:url';
|
|
62
|
+
import { parseHookEvent, toolName, commandOf, findInvocations, rawToolResponse, readStdinBounded } from './hook-input.mjs';
|
|
63
|
+
|
|
64
|
+
const MANAGED_TOOLS = ['gh', 'vercel', 'netlify', 'npm', 'git'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every managed invocation in `command`, structurally classified. Reuses hook-input.mjs's
|
|
68
|
+
* findInvocations() so npx/bunx/pnpx wrapping and executable-position rules are shared with every
|
|
69
|
+
* other gate in this repo instead of re-derived here (the ADR's explicit "do not reimplement").
|
|
70
|
+
*/
|
|
71
|
+
export function detectManagedInvocations(command) {
|
|
72
|
+
const hits = [];
|
|
73
|
+
for (const inv of findInvocations(command, MANAGED_TOOLS)) {
|
|
74
|
+
if (inv.tool === 'npm') { if (inv.args[0] === 'publish') hits.push({ tool: 'npm-publish', args: inv.args }); }
|
|
75
|
+
else if (inv.tool === 'git') { if (inv.args[0] === 'push') hits.push({ tool: 'git-push', args: inv.args }); }
|
|
76
|
+
else hits.push({ tool: inv.tool, args: inv.args });
|
|
77
|
+
}
|
|
78
|
+
return hits;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Raw Bash tool_response -> a small typed CliOutcome (DDD-0013 anti-corruption boundary: "the
|
|
83
|
+
* domain holds CliOutcome {exitClass, firstActionableLine}, never raw host JSON"). Defensive by
|
|
84
|
+
* construction per the VERIFY-FIRST note above: honors an explicit numeric exit code if a future
|
|
85
|
+
* Claude Code version ever adds one; otherwise falls back to "stderr is non-empty" as the best
|
|
86
|
+
* available signal from the shape actually observed.
|
|
87
|
+
*/
|
|
88
|
+
export function cliOutcome(raw) {
|
|
89
|
+
if (!raw || typeof raw !== 'object') return { failed: false, firstActionableLine: '' };
|
|
90
|
+
const exitCode = typeof raw.exitCode === 'number' ? raw.exitCode
|
|
91
|
+
: typeof raw.exit_code === 'number' ? raw.exit_code : null;
|
|
92
|
+
const stderr = typeof raw.stderr === 'string' ? raw.stderr : '';
|
|
93
|
+
const stdout = typeof raw.stdout === 'string' ? raw.stdout : '';
|
|
94
|
+
const failed = exitCode !== null ? exitCode !== 0 : stderr.trim().length > 0;
|
|
95
|
+
const firstActionableLine = (stderr.trim().split('\n')[0] || stdout.trim().split('\n')[0] || '').slice(0, 200);
|
|
96
|
+
return { failed, firstActionableLine };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseRepoSlug(remoteUrl) {
|
|
100
|
+
const m = /github\.com[:/]+([^/]+)\/([^/.]+?)(\.git)?$/.exec(String(remoteUrl || '').trim());
|
|
101
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The (repo, sha) a push/observation belongs to, from plain READ-ONLY git plumbing — never `gh`.
|
|
105
|
+
* The observer needs no auth; only the poller (scripts/signal-watch.mjs) talks to GitHub's API. */
|
|
106
|
+
function resolveGitContext(cwd) {
|
|
107
|
+
let sha = null;
|
|
108
|
+
let repo = null;
|
|
109
|
+
try {
|
|
110
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 2000 });
|
|
111
|
+
if (r.status === 0) sha = r.stdout.trim();
|
|
112
|
+
} catch { /* not a git repo, or git missing */ }
|
|
113
|
+
try {
|
|
114
|
+
const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd, encoding: 'utf8', timeout: 2000 });
|
|
115
|
+
if (r.status === 0) repo = parseRepoSlug(r.stdout);
|
|
116
|
+
} catch { /* no remote configured */ }
|
|
117
|
+
return { sha, repo };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function signalDir() {
|
|
121
|
+
return process.env.RUVNET_SIGNAL_DIR
|
|
122
|
+
|| path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'external-signals');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function pendingPath() { return process.env.SIGNAL_WATCH_PENDING || path.join(signalDir(), 'pending.jsonl'); }
|
|
126
|
+
|
|
127
|
+
/** Append ONE SignalDebt-open event. SINGLE WRITER of pending.jsonl (DDD-0013 §Context 2 inv. 3). */
|
|
128
|
+
export function appendDebt(event, file = pendingPath()) {
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n');
|
|
132
|
+
} catch { /* observation never blocks, never throws (DDD invariant 4) */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The pure decision: given a parsed hook event, what debts (if any) does it open, and what advisory
|
|
137
|
+
* lines (if any) does it print? Exported so a test can drive it directly with a constructed event,
|
|
138
|
+
* in addition to the subprocess-level tests that feed real envelope JSON on stdin.
|
|
139
|
+
*/
|
|
140
|
+
export function evaluate(ev, { cwd = process.cwd(), file = pendingPath() } = {}) {
|
|
141
|
+
const result = { debts: [], lines: [] };
|
|
142
|
+
if (toolName(ev) !== 'Bash') return result;
|
|
143
|
+
const command = commandOf(ev);
|
|
144
|
+
const hits = detectManagedInvocations(command);
|
|
145
|
+
if (!hits.length) return result;
|
|
146
|
+
|
|
147
|
+
const outcome = cliOutcome(rawToolResponse(ev));
|
|
148
|
+
for (const hit of hits) {
|
|
149
|
+
if (hit.tool === 'git-push') {
|
|
150
|
+
if (outcome.failed) continue; // nothing landed on origin — there is no SHA to poll a verdict for
|
|
151
|
+
const { sha, repo } = resolveGitContext(cwd);
|
|
152
|
+
if (!sha || !repo) continue; // can't key a debt without both halves of (repo, ref)
|
|
153
|
+
const debt = {
|
|
154
|
+
ts: new Date().toISOString(), kind: 'git-push', repo, ref: sha,
|
|
155
|
+
toolUseId: ev.tool_use_id || null, sessionId: ev.session_id || null,
|
|
156
|
+
};
|
|
157
|
+
appendDebt(debt, file);
|
|
158
|
+
result.debts.push(debt);
|
|
159
|
+
} else if (outcome.failed) {
|
|
160
|
+
const debt = {
|
|
161
|
+
ts: new Date().toISOString(), kind: 'cli-exit-nonzero', tool: hit.tool,
|
|
162
|
+
firstActionableLine: outcome.firstActionableLine,
|
|
163
|
+
toolUseId: ev.tool_use_id || null, sessionId: ev.session_id || null,
|
|
164
|
+
};
|
|
165
|
+
appendDebt(debt, file);
|
|
166
|
+
result.debts.push(debt);
|
|
167
|
+
result.lines.push(`[RuvNet Brain — external signal] ${hit.tool} command failed: ${outcome.firstActionableLine || '(no output captured)'}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readHookInput() {
|
|
174
|
+
if (process.stdin.isTTY) return null; // never block on a TTY with nothing to give
|
|
175
|
+
try { return parseHookEvent((await readStdinBounded()).toString('utf8')); } catch { return null; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function main() {
|
|
179
|
+
const ev = await readHookInput();
|
|
180
|
+
if (!ev) return;
|
|
181
|
+
const { lines } = evaluate(ev);
|
|
182
|
+
for (const l of lines) console.log(l);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isMain() {
|
|
186
|
+
try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
187
|
+
catch { return false; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (isMain()) {
|
|
191
|
+
try { await main(); } catch { /* fail open, always — see CONTRACT above */ }
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// unprompted-runtime.mjs — THE enforcement chokepoint for unprompted speech (ADR-040 / DDD-0004 §"The
|
|
3
|
+
// enforcement chokepoint"). This is the CORE of the 4.0 dial-scope work. Read this header as the
|
|
4
|
+
// contract every producer, test, and later hooks.json rewrite must build to.
|
|
5
|
+
//
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// WHY THIS FILE EXISTS. DDD-0004 states the invariant verbatim: "Every unprompted utterance passes
|
|
8
|
+
// through ONE runtime that reads the level and the DismissalLedger and alone decides whether bytes
|
|
9
|
+
// reach the user. An emitter returns a structured candidate; it never writes user-facing bytes
|
|
10
|
+
// directly. Raw text from an emitter is a protocol violation — dropped, not forwarded." Until this
|
|
11
|
+
// build no such runtime existed: anticipate.sh read the dial itself, lesson-gate read no dial at all,
|
|
12
|
+
// and both were wired BARE in hooks.json (`bash … || true`) instead of through the shim. This file is
|
|
13
|
+
// that ONE runtime. It is the SOLE writer of user-facing bytes for unprompted hooks.
|
|
14
|
+
//
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// THE SEAM (build to THIS — do not invent a parallel one).
|
|
17
|
+
//
|
|
18
|
+
// INVOCATION. hook-shim.mjs dispatches this file under the single id 'unprompted-speech', forwarding
|
|
19
|
+
// the Claude Code event name as argv:
|
|
20
|
+
// node hook-shim.mjs unprompted-speech <CCEventName> → node unprompted-runtime.mjs <CCEventName>
|
|
21
|
+
// <CCEventName> is the token hooks.json passes (a real CC event like "UserPromptSubmit", or an
|
|
22
|
+
// internal sub-event like "PreToolUse-bash"/"PreToolUse-write" that distinguishes which producer to
|
|
23
|
+
// run — the same tokens lesson-hooks.sh already switches on). The CC hook JSON payload arrives on this
|
|
24
|
+
// process's stdin, exactly as it would for a bare hook.
|
|
25
|
+
//
|
|
26
|
+
// PRODUCER CANDIDATE MODE. For the given event the runtime spawns the real producer(s) as CAPTURED
|
|
27
|
+
// child processes (stdio piped, NEVER inherited) with env RUVNET_EMIT_CANDIDATES=1 and the payload on
|
|
28
|
+
// their stdin. A producer in candidate mode writes ZERO user-facing bytes and instead prints ONE JSON
|
|
29
|
+
// object per line to stdout:
|
|
30
|
+
// {"channel":"advocacy|promotion|lesson|alarm","effect":"advisory|block","copy":"<text>","hookEventName":"<CCevent>"}
|
|
31
|
+
// advocacy/promotion candidates additionally carry: "findingId","severity","observationHash".
|
|
32
|
+
// With RUVNET_EMIT_CANDIDATES unset a producer behaves EXACTLY as today (its own CLI/direct output) —
|
|
33
|
+
// candidate mode is purely additive. The runtime captures every producer's stdout, discards its
|
|
34
|
+
// stderr, and never lets a producer touch the real process streams.
|
|
35
|
+
//
|
|
36
|
+
// PER-CHANNEL POLICY (applied by the runtime to each parsed candidate, keyed on the candidate's own
|
|
37
|
+
// `channel`, never on which producer emitted it):
|
|
38
|
+
// advocacy → honour the advocacy dial (settings `.settings.advocacy`) AND the DismissalLedger
|
|
39
|
+
// (advocacy-outcomes.shouldStillOffer). Off ⇒ dropped. Suppressed ⇒ dropped. On delivery
|
|
40
|
+
// the runtime records the OFFERED denominator centrally via advocacy-outcomes.record.
|
|
41
|
+
// The real modules are reused — suppression is NOT hand-rolled here.
|
|
42
|
+
// promotion → onboarding policy + advocacy level: delivered only at advocacy `all` (DDD-0004's
|
|
43
|
+
// "silenced by anything below all"); the once-per-install / never-repeat part is the
|
|
44
|
+
// promotion producer's own job (it emits a candidate only when it is due).
|
|
45
|
+
// lesson → NEVER the advocacy dial. The lesson producer (lesson-gate via lesson-hooks) owns its
|
|
46
|
+
// frequency cap, project scope, and blocking opt-in. `effect:'advisory'` is delivered as
|
|
47
|
+
// a nudge; `effect:'block'` becomes exit 2 with the reason on stderr and byte-empty
|
|
48
|
+
// stdout — an opted-in refusal is never swallowed.
|
|
49
|
+
// alarm → always delivered. Not gated by anything. Silence here = a broken install looking healthy.
|
|
50
|
+
//
|
|
51
|
+
// DELIVERY (the runtime writes the final envelope to the REAL streams, itself):
|
|
52
|
+
// advisory → exit 0, stdout = {"hookSpecificOutput":{"hookEventName":…, "additionalContext":…}}.
|
|
53
|
+
// block → exit 2, reason on stderr, stdout byte-empty.
|
|
54
|
+
// A block (only ever from the lesson channel) dominates: if any block survives policy, advisories are
|
|
55
|
+
// discarded and the runtime exits 2. Otherwise every surviving advisory copy is concatenated into ONE
|
|
56
|
+
// additionalContext string under ONE envelope (two JSON documents on stdout parse as neither).
|
|
57
|
+
//
|
|
58
|
+
// THE DROP RULE that makes "raw bytes are a protocol violation" mechanically true: on the ADVISORY
|
|
59
|
+
// path, invalid JSON / a non-object / an unknown channel / an unknown effect / a missing copy / a
|
|
60
|
+
// missing required advocacy field is SILENTLY dropped. A rogue producer that prints raw bytes instead
|
|
61
|
+
// of candidate JSON therefore reaches NEITHER user stream. A block is only ever produced by a VALID
|
|
62
|
+
// lesson candidate with `effect:'block'`; raw bytes can never manufacture a refusal.
|
|
63
|
+
//
|
|
64
|
+
// FAIL-SAFE. This never throws out to the caller. Any internal failure resolves toward SILENCE (exit
|
|
65
|
+
// 0, empty stdout) — never toward speaking, and never toward a spurious refusal.
|
|
66
|
+
//
|
|
67
|
+
// NON-NEGOTIABLE INVARIANTS (each proven by a test that breaks it):
|
|
68
|
+
// 1. An opted-in BLOCK lesson still exits 2 with byte-empty stdout.
|
|
69
|
+
// 2. advocacy=off ⇒ an advocacy candidate yields exit 0 and byte-EXACT stdout === "".
|
|
70
|
+
// 3. A rogue producer printing raw bytes ⇒ nothing reaches either user stream on the advisory path.
|
|
71
|
+
// 4. (Registry-test territory, not this file) a bare unprompted line in hooks.json fails the test.
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
import fs from 'node:fs';
|
|
75
|
+
import path from 'node:path';
|
|
76
|
+
import { spawnSync } from 'node:child_process';
|
|
77
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
78
|
+
import { readStdinBounded } from './hook-input.mjs';
|
|
79
|
+
|
|
80
|
+
// <codeRoot>/plugin/scripts/unprompted-runtime.mjs → <codeRoot>. Resolved from THIS file's own
|
|
81
|
+
// location so it is correct under the Stable Spine (an immutable versions/<gen> tree) and in a dev
|
|
82
|
+
// checkout alike — hook-shim.mjs has already chosen the tree by the time it dispatches this body.
|
|
83
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
84
|
+
const SCRIPTS_DIR = path.dirname(SELF); // <codeRoot>/plugin/scripts
|
|
85
|
+
const CODE_ROOT = path.resolve(SCRIPTS_DIR, '..', '..'); // <codeRoot>
|
|
86
|
+
|
|
87
|
+
// The CC event name the shim forwarded. No event → nothing to run; stay silent.
|
|
88
|
+
const EVENT = process.argv[2] || '';
|
|
89
|
+
|
|
90
|
+
// Bound the whole runtime well under the 5s hook budget: producers run sequentially and each has its
|
|
91
|
+
// own internal watchdog, but a backstop timeout here means a wedged producer can never hang the turn.
|
|
92
|
+
const PRODUCER_TIMEOUT_MS = Number(process.env.RUVNET_UNPROMPTED_TIMEOUT_MS) || 4000;
|
|
93
|
+
const MAX_BUFFER = 1 << 20;
|
|
94
|
+
|
|
95
|
+
const VALID_CHANNELS = new Set(['advocacy', 'promotion', 'lesson', 'alarm']);
|
|
96
|
+
const VALID_EFFECTS = new Set(['advisory', 'block']);
|
|
97
|
+
|
|
98
|
+
/** Exit 0 with byte-empty stdout — the fail-safe and the "nothing to say" path. */
|
|
99
|
+
function silent() { process.exit(0); }
|
|
100
|
+
|
|
101
|
+
// ── Producer registry: CC event token → the producers to spawn in candidate mode ──────────────────
|
|
102
|
+
// Each producer is an argv array + whether it is fed the payload on stdin. The channel is NOT declared
|
|
103
|
+
// here — it travels on each emitted candidate, so one producer may legitimately emit more than one
|
|
104
|
+
// channel and the runtime still routes every line by its own `channel`.
|
|
105
|
+
//
|
|
106
|
+
// Producers are the adapters that already own event→trigger mapping and payload parsing (lesson-hooks
|
|
107
|
+
// maps "PreToolUse-bash" → the mutate-machine trigger and pulls session_id/command off stdin;
|
|
108
|
+
// anticipate reads the prompt off stdin). Keeping that mapping in the producer layer is deliberate:
|
|
109
|
+
// this core file decides WHETHER bytes reach the user, not WHAT each producer looks for.
|
|
110
|
+
//
|
|
111
|
+
// TEST SEAM: RUVNET_UNPROMPTED_PRODUCERS, when set to a JSON array of {argv:[...],feedStdin:bool},
|
|
112
|
+
// REPLACES the built-in registry for every event — so a test can inject a fake block-emitter, an
|
|
113
|
+
// advocacy-emitter, or a rogue raw-bytes emitter without touching real producers.
|
|
114
|
+
// `channels` binds each producer to the ONLY channels it is authorised to emit (GPT-5.6-Sol anti-spoof).
|
|
115
|
+
// Without it, any producer could emit `{channel:'alarm'}` (always-delivered) or `lesson:block` (force
|
|
116
|
+
// exit 2) and bypass its intended per-channel policy — defeating the whole point of the chokepoint.
|
|
117
|
+
const ANTICIPATE = { argv: ['/bin/bash', path.join(SCRIPTS_DIR, 'anticipate.sh')], feedStdin: true, channels: ['advocacy', 'promotion'] };
|
|
118
|
+
const lesson = (subEvent) => ({ argv: ['/bin/bash', path.join(SCRIPTS_DIR, 'lesson-hooks.sh'), subEvent], feedStdin: true, channels: ['lesson'] });
|
|
119
|
+
|
|
120
|
+
const BUILTIN_REGISTRY = {
|
|
121
|
+
'UserPromptSubmit': [ANTICIPATE, lesson('UserPromptSubmit')],
|
|
122
|
+
'PreToolUse-write': [lesson('PreToolUse-write')],
|
|
123
|
+
'PreToolUse-bash': [lesson('PreToolUse-bash')],
|
|
124
|
+
'PreToolUse-push': [lesson('PreToolUse-push')],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function resolveProducers(event) {
|
|
128
|
+
const override = process.env.RUVNET_UNPROMPTED_PRODUCERS;
|
|
129
|
+
if (override) {
|
|
130
|
+
try {
|
|
131
|
+
const specs = JSON.parse(override);
|
|
132
|
+
if (Array.isArray(specs)) {
|
|
133
|
+
return specs
|
|
134
|
+
.filter((s) => s && Array.isArray(s.argv) && s.argv.length && s.argv.every((a) => typeof a === 'string'))
|
|
135
|
+
.map((s) => ({
|
|
136
|
+
argv: s.argv,
|
|
137
|
+
feedStdin: s.feedStdin !== false,
|
|
138
|
+
// The test seam may declare `channels` to exercise the anti-spoof; absent, it defaults to all
|
|
139
|
+
// (a test injects a trusted fake). Production spoof-protection lives on the built-in entries.
|
|
140
|
+
channels: Array.isArray(s.channels) ? s.channels.filter((c) => VALID_CHANNELS.has(c)) : [...VALID_CHANNELS],
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
} catch { /* malformed override → fall through to the built-in registry */ }
|
|
144
|
+
}
|
|
145
|
+
return BUILTIN_REGISTRY[event] || [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Read the payload ONCE, forward the same bytes to every producer ────────────────────────────────
|
|
149
|
+
// Claude Code pipes the hook JSON and closes stdin, so a synchronous read to EOF is safe and simplest.
|
|
150
|
+
// GUARD: an interactive/attached stdin (a TTY) is NOT piped and would block readFileSync(0) forever —
|
|
151
|
+
// so read only when fd 0 is a real pipe/file. A manual run with no redirect yields empty, not a hang.
|
|
152
|
+
let payload = Buffer.alloc(0);
|
|
153
|
+
if (!process.stdin.isTTY) {
|
|
154
|
+
try { payload = await readStdinBounded(); } catch { payload = Buffer.alloc(0); }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// AN UNPROMPTED UTTERANCE MUST BE OCCASIONED BY A REAL EVENT (fixed 2026-07-27).
|
|
158
|
+
//
|
|
159
|
+
// Measured on origin/main, the same runtime, three inputs:
|
|
160
|
+
//
|
|
161
|
+
// empty stdin -> 2453 bytes, exit 0
|
|
162
|
+
// garbage -> 2453 bytes
|
|
163
|
+
// real payload -> 0 bytes
|
|
164
|
+
//
|
|
165
|
+
// Read those together: it was silent when a real event arrived and spoke when none did. The producers
|
|
166
|
+
// downstream match triggers that are true unconditionally (report-status, claim-done), so with no
|
|
167
|
+
// payload to constrain them they emit on every fire — payload-independent speech, which is noise with
|
|
168
|
+
// a JSON envelope around it, injected into the user's context.
|
|
169
|
+
//
|
|
170
|
+
// The header already commits to the shape of this: "never speak on a guess" is why an unknown event
|
|
171
|
+
// stays silent. A payload that is absent, truncated, or not a JSON object is the same class of
|
|
172
|
+
// not-knowing, and resolves the same way — toward silence, per the FAIL-SAFE rule above. Claude Code
|
|
173
|
+
// always writes a JSON object and closes, so no real fire is affected; the test seam's fixtures all
|
|
174
|
+
// pass a real payload for the same reason.
|
|
175
|
+
let event = null;
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(payload.toString('utf8'));
|
|
178
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) event = parsed;
|
|
179
|
+
} catch { /* not JSON → no occasion → silence, below */ }
|
|
180
|
+
if (!event) silent();
|
|
181
|
+
|
|
182
|
+
const producers = resolveProducers(EVENT);
|
|
183
|
+
if (!producers.length) silent(); // unknown event, or nothing wired for it — never speak on a guess
|
|
184
|
+
|
|
185
|
+
// ── Spawn producers as CAPTURED children, collect candidate lines ──────────────────────────────────
|
|
186
|
+
// stdio is piped (input buffer for stdin; stdout/stderr captured into the result). NOTHING a producer
|
|
187
|
+
// writes touches the real streams — that is what makes the runtime the sole writer, and what drops a
|
|
188
|
+
// rogue producer's raw bytes before they can reach a terminal.
|
|
189
|
+
// ONE global deadline for ALL producers combined (GPT-5.6-Sol): sequential per-producer timeouts could
|
|
190
|
+
// otherwise sum past the 5s hook budget. Each producer gets only the remaining budget; once it is spent,
|
|
191
|
+
// no further producer runs. rawLines carries each line WITH its producer's authorised channel set.
|
|
192
|
+
const rawLines = []; // { s, channels }
|
|
193
|
+
const DEADLINE = Date.now() + PRODUCER_TIMEOUT_MS;
|
|
194
|
+
for (const p of producers) {
|
|
195
|
+
const remaining = DEADLINE - Date.now();
|
|
196
|
+
if (remaining <= 0) break; // global budget spent → run no more producers
|
|
197
|
+
let out = '';
|
|
198
|
+
try {
|
|
199
|
+
const r = spawnSync(p.argv[0], p.argv.slice(1), {
|
|
200
|
+
input: p.feedStdin ? payload : Buffer.alloc(0),
|
|
201
|
+
env: { ...process.env, RUVNET_EMIT_CANDIDATES: '1' },
|
|
202
|
+
timeout: remaining,
|
|
203
|
+
maxBuffer: MAX_BUFFER,
|
|
204
|
+
});
|
|
205
|
+
// FAIL CLOSED (GPT-5.6-Sol): a producer that errored, exited non-zero, was signalled (a timeout kill),
|
|
206
|
+
// or overflowed maxBuffer has UNTRUSTWORTHY partial output — discard it, never deliver a fragment.
|
|
207
|
+
if (!r.error && r.status === 0 && !r.signal && r.stdout) out = r.stdout.toString('utf8');
|
|
208
|
+
} catch { out = ''; } // a failed spawn contributes no candidates — never a failure of the turn
|
|
209
|
+
for (const line of out.split('\n')) {
|
|
210
|
+
const s = line.trim();
|
|
211
|
+
if (s) rawLines.push({ s, channels: p.channels });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Parse candidates. Anything that is not a well-formed candidate object is dropped here, which is
|
|
216
|
+
// where the "raw bytes are a protocol violation" guarantee is actually enforced. ───────────────────
|
|
217
|
+
const MAX_COPY = 8192; // a real advisory is short; cap so a rogue/buggy 900KB emission cannot bloat delivery
|
|
218
|
+
const candidates = [];
|
|
219
|
+
for (const { s, channels } of rawLines) {
|
|
220
|
+
let c;
|
|
221
|
+
try { c = JSON.parse(s); } catch { continue; } // not JSON → drop (a rogue's raw line)
|
|
222
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) continue;
|
|
223
|
+
if (!VALID_CHANNELS.has(c.channel)) continue; // unknown/absent channel → drop
|
|
224
|
+
if (!channels.includes(c.channel)) continue; // ANTI-SPOOF (GPT-5.6-Sol): this producer is
|
|
225
|
+
// not authorised for this channel → drop
|
|
226
|
+
if (!VALID_EFFECTS.has(c.effect)) continue; // unknown/absent effect → drop
|
|
227
|
+
if (typeof c.copy !== 'string' || !c.copy.trim()) continue; // nothing to say → drop
|
|
228
|
+
c.copy = c.copy.slice(0, MAX_COPY); // cap size; delivery below is synchronous
|
|
229
|
+
candidates.push(c);
|
|
230
|
+
}
|
|
231
|
+
if (!candidates.length) silent();
|
|
232
|
+
|
|
233
|
+
// ── Lazy access to the REAL dial read and the REAL DismissalLedger. Reused, not re-implemented. ────
|
|
234
|
+
// The dial comes from user-settings.loadSettings() (the versioned-envelope reader that honours
|
|
235
|
+
// RUVNET_SETTINGS_FILE); suppression comes from advocacy-outcomes.shouldStillOffer()/record(). Both
|
|
236
|
+
// are loaded only if an advocacy/promotion candidate is actually present, so a lesson- or alarm-only
|
|
237
|
+
// event pays nothing for them.
|
|
238
|
+
const SETTINGS_MODULE = process.env.RUVNET_USER_SETTINGS_MODULE || path.join(CODE_ROOT, 'scripts', 'user-settings.mjs');
|
|
239
|
+
const ADVOCACY_MODULE = process.env.RUVNET_ADVOCACY_OUTCOMES_MODULE || path.join(CODE_ROOT, 'scripts', 'advocacy-outcomes.mjs');
|
|
240
|
+
|
|
241
|
+
// THE 1–5 DIAL (ADR-052). This runtime is the SINGLE enforcement chokepoint (DDD-0004): the level maps
|
|
242
|
+
// to exactly what each channel is allowed to deliver, and no producer decides. LEVEL_POLICY is the one
|
|
243
|
+
// source of truth for that mapping — it is mirrored in the human copy of user-settings.mjs's `levels`,
|
|
244
|
+
// and the two must move together.
|
|
245
|
+
// 1 Only-when-I-ask : nothing unprompted (alarms still fire)
|
|
246
|
+
// 2 Critical-only : advocacy for HIGH-severity findings only; no promotions
|
|
247
|
+
// 3 Balanced : advocacy for relevant findings; no promotions ← default
|
|
248
|
+
// 4 Proactive : advocacy + lesson-promotion nudges
|
|
249
|
+
// 5 Maximum help : advocacy + promotions (broadest)
|
|
250
|
+
const LEVEL_POLICY = {
|
|
251
|
+
1: { advocacy: false, promotion: false, severityFloor: null },
|
|
252
|
+
2: { advocacy: true, promotion: false, severityFloor: 'high' },
|
|
253
|
+
3: { advocacy: true, promotion: false, severityFloor: null },
|
|
254
|
+
4: { advocacy: true, promotion: true, severityFloor: null },
|
|
255
|
+
5: { advocacy: true, promotion: true, severityFloor: null },
|
|
256
|
+
};
|
|
257
|
+
const LEGACY_LEVEL = { off: 1, 'important-only': 3, all: 4 }; // defensive: map a raw pre-migration value too
|
|
258
|
+
|
|
259
|
+
let _level;
|
|
260
|
+
async function advocacyLevel() {
|
|
261
|
+
if (_level !== undefined) return _level;
|
|
262
|
+
// Default 3 (Balanced) — matches user-settings' default AND preserves the old 'important-only'
|
|
263
|
+
// behaviour (advocacy on, promotion off) for anyone who never touched the setting.
|
|
264
|
+
_level = 3;
|
|
265
|
+
try {
|
|
266
|
+
const m = await import(pathToFileURL(SETTINGS_MODULE).href);
|
|
267
|
+
if (typeof m.loadSettings === 'function') {
|
|
268
|
+
const v = m.loadSettings().values?.advocacy; // loadSettings already migrates legacy strings → 1-5
|
|
269
|
+
const n = typeof v === 'number' ? v : LEGACY_LEVEL[v];
|
|
270
|
+
if (Number.isInteger(n) && n >= 1 && n <= 5) _level = n;
|
|
271
|
+
}
|
|
272
|
+
} catch { /* keep the safe default */ }
|
|
273
|
+
return _level;
|
|
274
|
+
}
|
|
275
|
+
async function policy() { return LEVEL_POLICY[await advocacyLevel()] || LEVEL_POLICY[3]; }
|
|
276
|
+
|
|
277
|
+
let _ledger = null; // { shouldStillOffer, record, ACTIONS } or null if unavailable
|
|
278
|
+
let _ledgerTried = false;
|
|
279
|
+
async function ledger() {
|
|
280
|
+
if (_ledgerTried) return _ledger;
|
|
281
|
+
_ledgerTried = true;
|
|
282
|
+
try {
|
|
283
|
+
const m = await import(pathToFileURL(ADVOCACY_MODULE).href);
|
|
284
|
+
if (typeof m.shouldStillOffer === 'function' && typeof m.record === 'function' && m.ACTIONS) {
|
|
285
|
+
_ledger = { shouldStillOffer: m.shouldStillOffer, record: m.record, ACTIONS: m.ACTIONS };
|
|
286
|
+
}
|
|
287
|
+
} catch { _ledger = null; }
|
|
288
|
+
return _ledger;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Apply per-channel policy ───────────────────────────────────────────────────────────────────────
|
|
292
|
+
const advisories = []; // { copy, hookEventName }
|
|
293
|
+
const blocks = []; // reason strings
|
|
294
|
+
|
|
295
|
+
for (const c of candidates) {
|
|
296
|
+
const hookEventName = typeof c.hookEventName === 'string' && c.hookEventName.trim() ? c.hookEventName.trim() : null;
|
|
297
|
+
const copy = c.copy.trim();
|
|
298
|
+
|
|
299
|
+
switch (c.channel) {
|
|
300
|
+
case 'alarm':
|
|
301
|
+
// Always delivered, never gated. Alarms inform; they do not refuse — a stray block effect on an
|
|
302
|
+
// alarm is treated as advisory rather than allowed to refuse the user's work.
|
|
303
|
+
advisories.push({ copy, hookEventName });
|
|
304
|
+
break;
|
|
305
|
+
|
|
306
|
+
case 'lesson':
|
|
307
|
+
if (c.effect === 'block') {
|
|
308
|
+
// The lesson producer only emits effect:'block' for a lesson the user personally opted into
|
|
309
|
+
// (blocking-optin.json). The runtime propagates that refusal untouched — it never invents one
|
|
310
|
+
// and never swallows one.
|
|
311
|
+
blocks.push(copy);
|
|
312
|
+
} else {
|
|
313
|
+
advisories.push({ copy, hookEventName });
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
|
|
317
|
+
case 'promotion': {
|
|
318
|
+
// DDD-0004's three-channels table + ADR-052's 1–5 dial: lesson-promotion nudges are delivered
|
|
319
|
+
// only at levels 4–5 (Proactive / Maximum). A block effect from promotion is a protocol
|
|
320
|
+
// violation → drop it.
|
|
321
|
+
if (c.effect !== 'advisory') break;
|
|
322
|
+
if (!(await policy()).promotion) break; // levels 1–3 ⇒ no promotion nudges
|
|
323
|
+
advisories.push({ copy, hookEventName });
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'advocacy': {
|
|
328
|
+
// The dial AND the DismissalLedger, enforced centrally on the candidate — this is the chokepoint's
|
|
329
|
+
// whole point: even a naive or rogue producer that emits an advocacy candidate is dropped when the
|
|
330
|
+
// dial is off or the finding is suppressed.
|
|
331
|
+
if (c.effect !== 'advisory') break; // advocacy cannot block
|
|
332
|
+
const findingId = typeof c.findingId === 'string' && c.findingId.trim() ? c.findingId.trim() : '';
|
|
333
|
+
if (!findingId) break; // malformed advocacy candidate → drop
|
|
334
|
+
const severity = typeof c.severity === 'string' && c.severity.trim() ? c.severity.trim() : null;
|
|
335
|
+
const pol = await policy();
|
|
336
|
+
if (!pol.advocacy) break; // level 1 ⇒ nothing (INVARIANT 2: exit 0, stdout "")
|
|
337
|
+
if (pol.severityFloor === 'high' && severity !== 'high' && severity !== 'critical') break; // level 2 ⇒ high-severity only
|
|
338
|
+
const led = await ledger();
|
|
339
|
+
if (!led) break; // cannot verify the ledger → stay silent (safe)
|
|
340
|
+
const stateHash = typeof c.observationHash === 'string' && c.observationHash.trim() ? c.observationHash.trim() : null;
|
|
341
|
+
let offer = true;
|
|
342
|
+
try { offer = led.shouldStillOffer(findingId, { severity, stateHash }); } catch { offer = false; }
|
|
343
|
+
if (!offer) break; // dismissed / budget spent → drop
|
|
344
|
+
// Deliver, and record the OFFERED denominator centrally (best-effort; recording never breaks
|
|
345
|
+
// the hook it measures). Moving OFFERED here is what makes precision computable at the one place
|
|
346
|
+
// that actually decides to show a card.
|
|
347
|
+
try { led.record({ id: findingId, action: led.ACTIONS.OFFERED, severity, stateHash }); } catch { /* a lost row costs one denominator, never the turn */ }
|
|
348
|
+
advisories.push({ copy, hookEventName });
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
default:
|
|
353
|
+
break; // unreachable — VALID_CHANNELS already filtered
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Deliver. A surviving block dominates everything. ───────────────────────────────────────────────
|
|
358
|
+
if (blocks.length) {
|
|
359
|
+
// Exit 2: stdout is ignored by the harness and MUST be byte-empty; stderr becomes the model's refusal
|
|
360
|
+
// reason. SYNCHRONOUS write (GPT-5.6-Sol) — process.exit() after an async write drops buffered bytes on a
|
|
361
|
+
// pipe (measured 900KB→8KB). Advisories collected this pass are discarded: a refusal supersedes.
|
|
362
|
+
try { fs.writeSync(2, blocks.join('\n') + '\n'); } catch { /* nothing else to do at the boundary */ }
|
|
363
|
+
process.exit(2);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!advisories.length) silent();
|
|
367
|
+
|
|
368
|
+
// hookEventName MUST name the firing CC event or the harness discards the envelope. DERIVE it from the
|
|
369
|
+
// runtime's own EVENT (GPT-5.6-Sol) rather than trusting a producer-stamped value — the runtime knows the
|
|
370
|
+
// real event and a producer must not be able to restamp it. "PreToolUse-bash" → "PreToolUse".
|
|
371
|
+
const hookEventName = EVENT ? EVENT.split('-')[0] : 'UserPromptSubmit';
|
|
372
|
+
|
|
373
|
+
// SYNCHRONOUS write — the truncation fix on the byte-owner itself (see the block path above).
|
|
374
|
+
const additionalContext = advisories.map((a) => a.copy).join('\n\n');
|
|
375
|
+
try { fs.writeSync(1, JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } })); }
|
|
376
|
+
catch { /* if even the final write fails, silence is the fail-safe */ }
|
|
377
|
+
process.exit(0);
|