ruvnet-brain 4.3.21 → 4.3.25
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/README.md +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// advocacy-route.mjs — THE RECOMMENDATION PRODUCER. One bounded UserPromptSubmit emitter that reads an
|
|
3
|
+
// ORDINARY build request, decides whether a shipped RuvNet building block would materially help it, and
|
|
4
|
+
// returns ONE structured advocacy candidate. It never writes user-facing bytes: unprompted-runtime.mjs
|
|
5
|
+
// is the sole writer (ADR-040 / DDD-0004), it alone applies the dial and the DismissalLedger, and it
|
|
6
|
+
// alone records the OFFERED denominator.
|
|
7
|
+
//
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// THE MEASURED GAP THIS CLOSES. On 2026-09-10 both hosts were given six ordinary requests with the
|
|
10
|
+
// Brain installed. Claude Code answered all six but took a MEDIAN 14.5 MINUTES and up to 39 tool calls
|
|
11
|
+
// — web searches, throwaway installs — before naming anything, and never once offered Agentic-QE for a
|
|
12
|
+
// quality-gates request. Codex called search_ruvnet 0/6 times and missed AIMDS for a customer-facing
|
|
13
|
+
// chatbot and model routing for a doubled LLM bill. The capability knowledge was present; the MOMENT of
|
|
14
|
+
// recommendation was not. This file is that moment: it fires on the prompt, before any tool call.
|
|
15
|
+
//
|
|
16
|
+
// WHAT IT IS NOT. It is not a second goal-match.mjs and not a second suppression policy — see
|
|
17
|
+
// advocacy-catalog.mjs's header for the measured reason goal-match structurally cannot serve this
|
|
18
|
+
// traffic (its GLOBAL_VETO rejects `customers`/`production`/`deploy` by design, and two of the six
|
|
19
|
+
// scenarios are vetoed on their first noun). Suppression is advocacy-outcomes.mjs. Delivery is
|
|
20
|
+
// unprompted-runtime.mjs. Prose is kb/capability-cards.md. This file contributes the matcher and the
|
|
21
|
+
// lifecycle adapter, nothing else.
|
|
22
|
+
//
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// WHAT THE CANDIDATE ACTUALLY IS — and the mistake it would be easy to make here.
|
|
25
|
+
//
|
|
26
|
+
// A UserPromptSubmit hook's stdout does NOT reach the user. It is injected into the MODEL's context as
|
|
27
|
+
// `additionalContext`. Writing user-facing prose into it produces a line the user never sees and a
|
|
28
|
+
// model that may or may not paraphrase it. So `copy` is phrased as ONE INSTRUCTION TO THE MODEL, with
|
|
29
|
+
// the user-facing sentence quoted inside it, and it tells the model to say it once and move on.
|
|
30
|
+
// Acceptance is therefore measured in two separate places, and they must not be conflated:
|
|
31
|
+
// CANDIDATE EMITTED — this file's job, observable at the hook boundary
|
|
32
|
+
// (`claude -p --include-hook-events`, or the tests here at the process boundary).
|
|
33
|
+
// USER SAW IT — the model's job, observable only in a real-host run. Reported separately.
|
|
34
|
+
//
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// LEXICAL, IN-PROCESS, NO CHILDREN. No embedder, no network, no search_ruvnet at prompt time. An
|
|
37
|
+
// embedder's cold init alone is ~3 s against a 3 s hooks.json timeout, so a semantic matcher here
|
|
38
|
+
// would not be a better matcher — it would be a dead one, silent in exactly the way a healthy one is
|
|
39
|
+
// silent. Grounding is not skipped, it is MOVED to the two places that can bear the cost: a test pins
|
|
40
|
+
// every claim to kb/capability-cards.md, and SKILL.md instructs the model to confirm with
|
|
41
|
+
// search_ruvnet before it builds.
|
|
42
|
+
//
|
|
43
|
+
// BUDGET, DERIVED NOT ASSERTED: hooks.json timeout 3000 ms > unprompted-runtime global producer
|
|
44
|
+
// deadline (default 4000 ms, but this producer self-bounds first) > BUDGET_MS 1500 ms. Every exit
|
|
45
|
+
// past the budget is SILENCE. tests/unit/advocacy-route-budget.test.mjs measures p95 over 20 COLD
|
|
46
|
+
// node invocations, because a warm in-process call proves nothing about the path that actually runs.
|
|
47
|
+
//
|
|
48
|
+
// CLI (read-only surfaces — a control this file renders must really exist):
|
|
49
|
+
// advocacy-route.mjs --summary precision over the route's own offers, JSON
|
|
50
|
+
// advocacy-route.mjs --sweep <sessionId> resolve that session's unresolved offers to `ignored`
|
|
51
|
+
// Kill switch: RUVNET_ADVOCACY_ROUTE=0
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
import fs from 'node:fs';
|
|
55
|
+
import os from 'node:os';
|
|
56
|
+
import path from 'node:path';
|
|
57
|
+
import crypto from 'node:crypto';
|
|
58
|
+
import { fileURLToPath } from 'node:url';
|
|
59
|
+
import { CAPABILITIES, INTENTS, MIN_CUES } from './advocacy-catalog.mjs';
|
|
60
|
+
import {
|
|
61
|
+
ACTIONS, record, shouldStillOffer, stateHashOf, precision, pendingOffers, reconcileIgnored,
|
|
62
|
+
} from './advocacy-outcomes.mjs';
|
|
63
|
+
|
|
64
|
+
const HOME = process.env.RUVNET_HOME_OVERRIDE || os.homedir();
|
|
65
|
+
|
|
66
|
+
/** Every id this route can ever name is namespaced, so it can never collide with a capability-registry
|
|
67
|
+
* key that anticipate.sh offers into the SAME ledger. One ledger, two producers, disjoint identities. */
|
|
68
|
+
export const FINDING_PREFIX = 'recommend:';
|
|
69
|
+
|
|
70
|
+
export const BUDGET_MS = Number(process.env.RUVNET_ADVOCACY_ROUTE_BUDGET_MS) || 1500;
|
|
71
|
+
|
|
72
|
+
/** At most ONE recommendation per session. anticipate.sh allows two; this route is louder per offer
|
|
73
|
+
* (it names a thing to install, not a switch to flip), so it gets the stricter ceiling. */
|
|
74
|
+
export const MAX_PER_SESSION = 1;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The sample floor for THIS route's precision, deliberately higher than advocacy-outcomes'
|
|
78
|
+
* MIN_PRECISION_SAMPLES (5). That constant governs the whole ledger; a route that offers at most once
|
|
79
|
+
* per session reaches five resolutions across five different sessions, where a single unlucky week
|
|
80
|
+
* would read as a verdict. Ten is the floor at which reporting a rate here is not noise. Below it the
|
|
81
|
+
* answer is `null` — "not yet judgeable" — never 0.
|
|
82
|
+
*/
|
|
83
|
+
export const ROUTE_MIN_SAMPLES = 10;
|
|
84
|
+
|
|
85
|
+
export const STATE_FILE = process.env.RUVNET_ADVOCACY_ROUTE_STATE
|
|
86
|
+
|| path.join(HOME, '.config', 'ruvnet-brain', 'advocacy-route-state.json');
|
|
87
|
+
const STATE_VERSION = 1;
|
|
88
|
+
const KEEP_SESSIONS = 20;
|
|
89
|
+
|
|
90
|
+
const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 16);
|
|
91
|
+
|
|
92
|
+
// ── Availability: cheap, in-process, and NEVER asserted as absence ────────────────────────────────
|
|
93
|
+
// We can see one npm root and the cwd. We cannot see nvm, pnpm, volta, bun, yarn PnP, or a monorepo
|
|
94
|
+
// workspace root. So a miss is reported as `unknown`, never `absent` — the same discipline
|
|
95
|
+
// capability-registry.mjs's header sets out ("'unknown' is a first-class state and it outranks 'off'
|
|
96
|
+
// every time a probe could not run"). The copy downstream says "install state unknown" in those words,
|
|
97
|
+
// so the model cannot relay a claim this file did not make.
|
|
98
|
+
// RUVNET_ADVOCACY_ROUTE_ROOTS is an EXCLUSIVE override, not an addition: when it is set these are the
|
|
99
|
+
// only places probed, bin directory included. A partial override is how a test "proving" the unknown
|
|
100
|
+
// path silently passes against the developer's own ~/.npm-global — measured here on 2026-09-11, where
|
|
101
|
+
// an override pointing at an empty directory still reported `installed` because the real `aqe` binary
|
|
102
|
+
// was found by the un-overridden half.
|
|
103
|
+
const PROBE_ROOTS = (process.env.RUVNET_ADVOCACY_ROUTE_ROOTS || '')
|
|
104
|
+
.split(path.delimiter).filter(Boolean);
|
|
105
|
+
function probeDirs() {
|
|
106
|
+
if (PROBE_ROOTS.length) return { modules: PROBE_ROOTS, bins: PROBE_ROOTS };
|
|
107
|
+
return {
|
|
108
|
+
modules: [path.join(process.cwd(), 'node_modules'), path.join(HOME, '.npm-global', 'lib', 'node_modules')],
|
|
109
|
+
bins: [path.join(HOME, '.npm-global', 'bin')],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function availabilityOf(capabilityId) {
|
|
113
|
+
const cap = CAPABILITIES[capabilityId];
|
|
114
|
+
if (!cap) return 'unknown';
|
|
115
|
+
try {
|
|
116
|
+
const { modules, bins } = probeDirs();
|
|
117
|
+
for (const root of modules) {
|
|
118
|
+
for (const pkg of cap.probe.pkgs) {
|
|
119
|
+
if (fs.existsSync(path.join(root, ...pkg.split('/')))) return 'installed';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const root of bins) {
|
|
123
|
+
for (const bin of cap.probe.bins) {
|
|
124
|
+
if (fs.existsSync(path.join(root, bin))) return 'installed';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch { /* a probe that cannot run is not evidence of absence */ }
|
|
128
|
+
return 'unknown';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── The matcher ───────────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
/**
|
|
133
|
+
* Which intent (if any) this prompt supports, with the cues that carried it.
|
|
134
|
+
*
|
|
135
|
+
* Returns null — SILENCE — for: a short prompt, no intent reaching MIN_CUES, or a tie nobody wins.
|
|
136
|
+
* The returned `cues` are the real matched sources, so every downstream claim is evidence-bound and a
|
|
137
|
+
* test can assert WHY a prompt matched rather than only that it did.
|
|
138
|
+
*/
|
|
139
|
+
export function classify(promptText) {
|
|
140
|
+
if (typeof promptText !== 'string') return null;
|
|
141
|
+
const text = promptText.trim().toLowerCase();
|
|
142
|
+
if (text.length < 20) return null; // "ok", "continue", "yes" — nothing to reason about
|
|
143
|
+
let best = null;
|
|
144
|
+
for (const intent of INTENTS) {
|
|
145
|
+
const cues = intent.cues.filter((re) => re.test(text)).map((re) => re.source);
|
|
146
|
+
if (cues.length < MIN_CUES) continue;
|
|
147
|
+
if (!best || cues.length > best.cues.length) best = { intent, capability: intent.capability, cues };
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Session state: the delivery-evidence record ───────────────────────────────────────────────────
|
|
153
|
+
// The outcome LEDGER is canonical for what became of an offer, and its row schema is fixed (id,
|
|
154
|
+
// action, at, project, severity, stateHash, scope) — correlation fields do not fit in it and must not
|
|
155
|
+
// be smuggled in. They live here instead: session id, offer id, prompt hash, timestamp. That is the
|
|
156
|
+
// division the ledger's own header asks for — it measures, this remembers.
|
|
157
|
+
function readState() {
|
|
158
|
+
try {
|
|
159
|
+
const j = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
160
|
+
if (j && typeof j === 'object' && j.version === STATE_VERSION && j.sessions && typeof j.sessions === 'object') return j;
|
|
161
|
+
} catch { /* absent or unreadable → defaults */ }
|
|
162
|
+
return { version: STATE_VERSION, sessions: {} };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Atomic, sibling temp file, entirely inside the config dir. Returns false on any failure, never throws. */
|
|
166
|
+
function writeState(st) {
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
169
|
+
const tmp = `${STATE_FILE}.tmp.${process.pid}`;
|
|
170
|
+
fs.writeFileSync(tmp, JSON.stringify(st, null, 2));
|
|
171
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
172
|
+
return true;
|
|
173
|
+
} catch { return false; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const offersOf = (st, sid) => (Array.isArray(st.sessions?.[sid]?.offers) ? st.sessions[sid].offers : []);
|
|
177
|
+
|
|
178
|
+
function putSession(st, sid, offers) {
|
|
179
|
+
st.sessions[sid] = { offers, ts: Date.now() };
|
|
180
|
+
st.sessions = Object.fromEntries(
|
|
181
|
+
Object.entries(st.sessions).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, KEEP_SESSIONS),
|
|
182
|
+
);
|
|
183
|
+
return st;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Lifecycle: offered → applied | dismissed | ignored, WITHOUT PreToolUse/PostToolUse ────────────
|
|
187
|
+
// Those interceptors are retired since 4.3.17 and stay retired, so "did the user act on it?" cannot be
|
|
188
|
+
// observed by watching tool calls. It is observed at the ONLY boundary still wired: the NEXT
|
|
189
|
+
// UserPromptSubmit. An offer is `pending` until the next prompt either accepts it or declines it, and
|
|
190
|
+
// `ignored` when the session ends with neither — swept at the SessionEnd capture boundary by
|
|
191
|
+
// sweepSession(), which continuity's session-snapshot hook calls.
|
|
192
|
+
//
|
|
193
|
+
// A BARE "yes"/"no" ONLY COUNTS WHEN IT CANNOT BE AMBIGUOUS: exactly one offer pending and a short
|
|
194
|
+
// prompt. Otherwise the capability must be named. Crediting an `applied` on a coincidental "ok" would
|
|
195
|
+
// inflate the one number that judges this feature, which is the failure advocacy-outcomes' header
|
|
196
|
+
// names first.
|
|
197
|
+
const ACCEPT_BARE = /^(y|yes|yeah|yep|ok|okay|sure|do it|go ahead|please do|sounds good|let'?s do it|use it)\b/i;
|
|
198
|
+
const DECLINE_BARE = /^(n|no|nope|nah|skip|not now|no thanks|leave it|don'?t)\b/i;
|
|
199
|
+
const acceptNamed = (id) => new RegExp(`\\b(use|add|wire|set ?up|install|try|go with|switch to)\\b[^.!?]{0,30}\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
200
|
+
const declineNamed = (id) => new RegExp(`\\b(no|not|don'?t|skip|drop|without|forget)\\b[^.!?]{0,30}\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve this session's still-pending offers against the prompt that just arrived.
|
|
204
|
+
*
|
|
205
|
+
* Writes at most one row per offer, through record(), and never throws: a lost transition costs one
|
|
206
|
+
* ledger row, never the turn. Returns what it actually resolved, so a test can assert the transition
|
|
207
|
+
* rather than infer it.
|
|
208
|
+
*/
|
|
209
|
+
export function resolvePriorOffers(promptText, sessionId, { file, state } = {}) {
|
|
210
|
+
const out = { applied: [], dismissed: [] };
|
|
211
|
+
const text = typeof promptText === 'string' ? promptText.trim() : '';
|
|
212
|
+
if (!text || !sessionId) return out;
|
|
213
|
+
const st = state || readState();
|
|
214
|
+
const offers = offersOf(st, sessionId).filter((o) => o && !o.resolved);
|
|
215
|
+
if (!offers.length) return out;
|
|
216
|
+
|
|
217
|
+
// THE LEDGER IS THE SOLE ARBITER, exactly as it is for reconcileIgnored(). This state file knows we
|
|
218
|
+
// DECIDED to offer; only the ledger knows the card was actually DELIVERED — the runtime writes the
|
|
219
|
+
// OFFERED row after the dial and the DismissalLedger have both let it through, and at advocacy=off
|
|
220
|
+
// it never writes one. Without this check a user whose dial is off could still be credited an
|
|
221
|
+
// `applied` for a card they were never shown, which puts a number in the numerator that describes
|
|
222
|
+
// nothing. A miss here leaves the offer pending; the SessionEnd sweep is likewise a no-op on it.
|
|
223
|
+
let deliverable;
|
|
224
|
+
try { deliverable = new Set(pendingOffers(file ? { file } : {}).map((p) => p.id)); } catch { return out; }
|
|
225
|
+
|
|
226
|
+
const bareOk = offers.length === 1 && text.length <= 80;
|
|
227
|
+
let changed = false;
|
|
228
|
+
for (const offer of offers) {
|
|
229
|
+
if (!deliverable.has(offer.id)) continue;
|
|
230
|
+
const cap = String(offer.capability || '');
|
|
231
|
+
let action = null;
|
|
232
|
+
if (cap && declineNamed(cap).test(text)) action = ACTIONS.DISMISSED;
|
|
233
|
+
else if (cap && acceptNamed(cap).test(text)) action = ACTIONS.APPLIED;
|
|
234
|
+
else if (bareOk && DECLINE_BARE.test(text)) action = ACTIONS.DISMISSED;
|
|
235
|
+
else if (bareOk && ACCEPT_BARE.test(text)) action = ACTIONS.APPLIED;
|
|
236
|
+
if (!action) continue;
|
|
237
|
+
let ok = false;
|
|
238
|
+
try {
|
|
239
|
+
ok = record({ id: offer.id, action, severity: offer.severity || 'normal' }, file ? { file } : {}).ok;
|
|
240
|
+
} catch { ok = false; }
|
|
241
|
+
if (!ok) continue; // ledger write failed → leave it pending; a silent "resolved" would be a lie
|
|
242
|
+
offer.resolved = action;
|
|
243
|
+
offer.resolvedAt = new Date().toISOString();
|
|
244
|
+
changed = true;
|
|
245
|
+
(action === ACTIONS.APPLIED ? out.applied : out.dismissed).push(offer.id);
|
|
246
|
+
}
|
|
247
|
+
if (changed && !state) writeState(st);
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* SessionEnd sweep: every offer this session left unresolved becomes `ignored`.
|
|
253
|
+
*
|
|
254
|
+
* EXPORTED FOR CONTINUITY — their session-snapshot hook calls this at the SessionEnd capture boundary.
|
|
255
|
+
* It delegates the decision to reconcileIgnored(), which verifies each id still has a real pending
|
|
256
|
+
* offer, so a stale or duplicated call is a no-op rather than a double count. Never throws.
|
|
257
|
+
*/
|
|
258
|
+
export function sweepSession(sessionId, { file, state } = {}) {
|
|
259
|
+
if (!sessionId) return [];
|
|
260
|
+
const st = state || readState();
|
|
261
|
+
const offers = offersOf(st, sessionId).filter((o) => o && !o.resolved && typeof o.id === 'string');
|
|
262
|
+
if (!offers.length) return [];
|
|
263
|
+
let done = [];
|
|
264
|
+
try { done = reconcileIgnored(offers.map((o) => o.id), file ? { file } : {}); } catch { done = []; }
|
|
265
|
+
if (!done.length) return [];
|
|
266
|
+
const swept = new Set(done);
|
|
267
|
+
for (const offer of offers) if (swept.has(offer.id)) { offer.resolved = ACTIONS.IGNORED; offer.resolvedAt = new Date().toISOString(); }
|
|
268
|
+
if (!state) writeState(st);
|
|
269
|
+
return done;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* This route's own precision, read-only. Restricted to `recommend:` ids so anticipate.sh's dormant-
|
|
274
|
+
* capability offers cannot flatter or drag it, and reported as null below ROUTE_MIN_SAMPLES.
|
|
275
|
+
* The lower bound comes from advocacy-outcomes.precision() — not recomputed here.
|
|
276
|
+
*/
|
|
277
|
+
export function summary({ file } = {}) {
|
|
278
|
+
// precision() filters by an EXACT id, not a prefix, so this sums one scoped read per capability
|
|
279
|
+
// rather than re-implementing loadOutcomes' reset-aware, position-ordered definition of a
|
|
280
|
+
// resolution here. Seven cheap reads beat a second copy of that logic drifting from the first.
|
|
281
|
+
const counts = { applied: 0, dismissed: 0, ignored: 0 };
|
|
282
|
+
let target = null;
|
|
283
|
+
for (const capId of Object.keys(CAPABILITIES)) {
|
|
284
|
+
try {
|
|
285
|
+
const p = precision({ ...(file ? { file } : {}), id: `${FINDING_PREFIX}${capId}` });
|
|
286
|
+
counts.applied += p.applied || 0;
|
|
287
|
+
counts.dismissed += p.dismissed || 0;
|
|
288
|
+
counts.ignored += p.ignored || 0;
|
|
289
|
+
target = p.target;
|
|
290
|
+
} catch { /* unreadable ledger → zeros, which `sufficient:false` below reports honestly */ }
|
|
291
|
+
}
|
|
292
|
+
let pending = 0;
|
|
293
|
+
try { pending = pendingOffers(file ? { file } : {}).filter((p) => String(p.id).startsWith(FINDING_PREFIX)).length; } catch { pending = 0; }
|
|
294
|
+
|
|
295
|
+
const resolved = counts.applied + counts.dismissed + counts.ignored;
|
|
296
|
+
const sufficient = resolved >= ROUTE_MIN_SAMPLES;
|
|
297
|
+
return {
|
|
298
|
+
scope: FINDING_PREFIX,
|
|
299
|
+
...counts,
|
|
300
|
+
resolved,
|
|
301
|
+
pending,
|
|
302
|
+
target,
|
|
303
|
+
minSamples: ROUTE_MIN_SAMPLES,
|
|
304
|
+
sufficient,
|
|
305
|
+
precision: sufficient ? +(counts.applied / resolved).toFixed(4) : null,
|
|
306
|
+
reason: sufficient ? null
|
|
307
|
+
: `only ${resolved} resolved offer(s) — below the ${ROUTE_MIN_SAMPLES}-sample floor, so precision is unknown, not zero`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Candidate construction ────────────────────────────────────────────────────────────────────────
|
|
312
|
+
/**
|
|
313
|
+
* The candidate, as DDD-0004's advocacy aggregate — an ADAPTER onto the existing identities, not a new
|
|
314
|
+
* schema. `findingId` is the Finding identity, `observationHash` the Observation fingerprint,
|
|
315
|
+
* `severity` the class the DismissalLedger budgets on, and the Remedy is carried as an executable
|
|
316
|
+
* `nextAction` with its `undo`. Everything the runtime needs it already understands; the extra fields
|
|
317
|
+
* ride along for correlation and are inert to it.
|
|
318
|
+
*
|
|
319
|
+
* SEVERITY IS ALWAYS `normal`, including for the safety intent. DISMISSAL_BUDGET gives `high` three
|
|
320
|
+
* lives, i.e. it re-fires twice after a refusal — and ADR-028 is explicit that one false alarm costs
|
|
321
|
+
* more trust than ten true ones earn. A recommendation the user has declined once is finished.
|
|
322
|
+
*
|
|
323
|
+
* The OBSERVATION is hashed over intent+capability, NOT over the prompt. Hashing the prompt would make
|
|
324
|
+
* every new wording a "state change" and hand the reprieve in shouldStillOffer() a way to reopen a
|
|
325
|
+
* settled dismissal on every turn — a dismissal that does not stick is the nag with extra steps.
|
|
326
|
+
*/
|
|
327
|
+
export function buildCandidate({ prompt, match, availability }) {
|
|
328
|
+
const cap = CAPABILITIES[match.capability];
|
|
329
|
+
if (!cap) return null;
|
|
330
|
+
const avail = availability === 'installed'
|
|
331
|
+
? `It is already installed here.`
|
|
332
|
+
: `Install state unknown from here — say so rather than claiming it is available.`;
|
|
333
|
+
const copy = [
|
|
334
|
+
`[RuvNet Brain — capability advocacy] If it genuinely fits, tell the user in ONE sentence: `
|
|
335
|
+
+ `"Consider ${cap.id} — ${cap.benefit}. Say 'use ${cap.id}' to proceed, or ignore this." ${avail}`,
|
|
336
|
+
`If they accept, the safe first step is \`${cap.nextAction}\` (undo: ${cap.undo}); confirm it with `
|
|
337
|
+
+ `search_ruvnet before you build. Say it once, do not expand it, then carry on with the actual work.`,
|
|
338
|
+
].join('\n');
|
|
339
|
+
return {
|
|
340
|
+
channel: 'advocacy',
|
|
341
|
+
effect: 'advisory',
|
|
342
|
+
hookEventName: 'UserPromptSubmit',
|
|
343
|
+
findingId: `${FINDING_PREFIX}${cap.id}`,
|
|
344
|
+
severity: 'normal',
|
|
345
|
+
observationHash: stateHashOf([`intent:${match.intent.id}`, `capability:${cap.id}`]),
|
|
346
|
+
copy,
|
|
347
|
+
capability: cap.id,
|
|
348
|
+
intent: match.intent.id,
|
|
349
|
+
fit: match.intent.fit,
|
|
350
|
+
availability: availability === 'installed' ? 'installed' : 'unknown',
|
|
351
|
+
nextAction: cap.nextAction,
|
|
352
|
+
undo: cap.undo,
|
|
353
|
+
cues: match.cues,
|
|
354
|
+
promptHash: sha(String(prompt || '').trim().toLowerCase()),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The whole decision, minus process IO. Returns the candidate to emit, or null for SILENCE, and says
|
|
360
|
+
* WHY it stayed silent so a test can distinguish "no intent" from "already said" from "suppressed" —
|
|
361
|
+
* three very different bugs that all look identical from the outside.
|
|
362
|
+
*/
|
|
363
|
+
export function decide({ prompt, sessionId, file, state, now = Date.now(), startedAt = Date.now() }) {
|
|
364
|
+
if (Date.now() - startedAt > BUDGET_MS) return { candidate: null, reason: 'budget-exceeded' };
|
|
365
|
+
const match = classify(prompt);
|
|
366
|
+
if (!match) return { candidate: null, reason: 'no-intent' };
|
|
367
|
+
const st = state || readState();
|
|
368
|
+
const offers = offersOf(st, sessionId);
|
|
369
|
+
if (offers.filter((o) => o && o.at).length >= MAX_PER_SESSION) return { candidate: null, reason: 'session-cap' };
|
|
370
|
+
if (offers.some((o) => o && o.capability === match.capability)) return { candidate: null, reason: 'already-offered' };
|
|
371
|
+
|
|
372
|
+
const id = `${FINDING_PREFIX}${match.capability}`;
|
|
373
|
+
const stateHash = stateHashOf([`intent:${match.intent.id}`, `capability:${match.capability}`]);
|
|
374
|
+
let allowed = true;
|
|
375
|
+
try { allowed = shouldStillOffer(id, { severity: 'normal', stateHash, ...(file ? { file } : {}) }); } catch { allowed = false; }
|
|
376
|
+
if (!allowed) return { candidate: null, reason: 'suppressed' };
|
|
377
|
+
|
|
378
|
+
const candidate = buildCandidate({ prompt, match, availability: availabilityOf(match.capability) });
|
|
379
|
+
if (!candidate) return { candidate: null, reason: 'no-card' };
|
|
380
|
+
if (Date.now() - startedAt > BUDGET_MS) return { candidate: null, reason: 'budget-exceeded' };
|
|
381
|
+
|
|
382
|
+
// PERSIST FIRST, SPEAK SECOND — anticipate.sh's SILENCE RULE 3, and the ordering is the whole rule.
|
|
383
|
+
// Killed between the two we lose one recommendation (silent, harmless). The other order risks
|
|
384
|
+
// speaking without remembering it, which repeats on the very next prompt; repeating is what gets a
|
|
385
|
+
// hook switched off for good.
|
|
386
|
+
offers.push({
|
|
387
|
+
id, capability: match.capability, intent: match.intent.id,
|
|
388
|
+
at: new Date(now).toISOString(), promptHash: candidate.promptHash,
|
|
389
|
+
sessionId, severity: 'normal', resolved: null,
|
|
390
|
+
});
|
|
391
|
+
putSession(st, sessionId, offers);
|
|
392
|
+
if (!state && !writeState(st)) return { candidate: null, reason: 'state-unwritable' };
|
|
393
|
+
return { candidate, reason: null };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
397
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
398
|
+
|
|
399
|
+
async function main() {
|
|
400
|
+
const startedAt = Date.now();
|
|
401
|
+
const arg = process.argv[2] || '';
|
|
402
|
+
|
|
403
|
+
if (arg === '--summary') { process.stdout.write(`${JSON.stringify(summary(), null, 2)}\n`); return 0; }
|
|
404
|
+
if (arg === '--sweep') {
|
|
405
|
+
const done = sweepSession(process.argv[3] || '');
|
|
406
|
+
process.stdout.write(`${JSON.stringify({ swept: done }, null, 2)}\n`);
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
if (arg) return 0; // an unknown flag is not an occasion to speak
|
|
410
|
+
|
|
411
|
+
switch (process.env.RUVNET_ADVOCACY_ROUTE) {
|
|
412
|
+
case '0': case 'off': case 'false': case 'no': return 0;
|
|
413
|
+
default: break;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// The payload arrives on stdin exactly as unprompted-runtime.mjs forwards it. A TTY is not a pipe
|
|
417
|
+
// and readFileSync(0) on one blocks forever, so a manual run with no redirect yields empty, not a hang.
|
|
418
|
+
let raw = '';
|
|
419
|
+
if (!process.stdin.isTTY) {
|
|
420
|
+
try {
|
|
421
|
+
const { readStdinBounded } = await import('./hook-input.mjs');
|
|
422
|
+
raw = (await readStdinBounded()).toString('utf8');
|
|
423
|
+
} catch { raw = ''; }
|
|
424
|
+
}
|
|
425
|
+
let payload = null;
|
|
426
|
+
try {
|
|
427
|
+
const p = JSON.parse(raw);
|
|
428
|
+
if (p && typeof p === 'object' && !Array.isArray(p)) payload = p;
|
|
429
|
+
} catch { /* not JSON → no occasion → silence */ }
|
|
430
|
+
if (!payload) return 0;
|
|
431
|
+
|
|
432
|
+
const prompt = [payload.prompt, payload.user_prompt, payload.input].find((v) => typeof v === 'string' && v.trim()) || '';
|
|
433
|
+
const sessionId = typeof payload.session_id === 'string' && payload.session_id.trim()
|
|
434
|
+
? payload.session_id.trim()
|
|
435
|
+
: `fallback:${process.cwd()}:${new Date().toISOString().slice(0, 10)}`;
|
|
436
|
+
|
|
437
|
+
// Lifecycle first: this prompt may be the ANSWER to the last one's offer. Doing it before the new
|
|
438
|
+
// decision is what lets "use agentic-qe" record an `applied` and still be classified on its merits.
|
|
439
|
+
try { resolvePriorOffers(prompt, sessionId); } catch { /* a lost transition costs one row, never the turn */ }
|
|
440
|
+
|
|
441
|
+
const { candidate } = decide({ prompt, sessionId, startedAt });
|
|
442
|
+
if (!candidate) return 0;
|
|
443
|
+
|
|
444
|
+
if (process.env.RUVNET_EMIT_CANDIDATES === '1') {
|
|
445
|
+
// CANDIDATE MODE: one JSON line, no prose. The runtime honours the dial and the DismissalLedger on
|
|
446
|
+
// it and records the OFFERED denominator centrally — so this path deliberately does NOT record
|
|
447
|
+
// OFFERED here, which would double-count precision's denominator.
|
|
448
|
+
process.stdout.write(`${JSON.stringify(candidate)}\n`);
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
// DIRECT MODE (a human running this file, or a host without the runtime). Here this process IS the
|
|
452
|
+
// writer, so it owns the denominator too.
|
|
453
|
+
try { record({ id: candidate.findingId, action: ACTIONS.OFFERED, severity: 'normal', stateHash: candidate.observationHash }); } catch { /* never break the surface we measure */ }
|
|
454
|
+
process.stdout.write(`${candidate.copy}\n`);
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (isMain) {
|
|
459
|
+
main().then((code) => process.exit(code || 0)).catch(() => process.exit(0));
|
|
460
|
+
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import fs from 'node:fs';
|
|
41
41
|
import path from 'node:path';
|
|
42
42
|
import os from 'node:os';
|
|
43
|
+
import crypto from 'node:crypto';
|
|
43
44
|
import { readStdinBounded } from './hook-input.mjs';
|
|
44
45
|
import {
|
|
45
46
|
auditCapabilityClaims,
|
|
@@ -182,10 +183,32 @@ if (has('--commit-to')) {
|
|
|
182
183
|
// without it, ending a turn is simply finishing, and this gate must stay silent.
|
|
183
184
|
const led = load();
|
|
184
185
|
const text = arg('--commit-to');
|
|
186
|
+
const at = new Date().toISOString();
|
|
185
187
|
if (text && !led.items.some((i) => i.text === text && !i.done)) {
|
|
186
|
-
led.items.push({ text, done: false, at
|
|
187
|
-
save(led);
|
|
188
|
+
led.items.push({ text, done: false, at });
|
|
188
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* THE BUG THIS CLOSES (found live 2026-09-12, in production use, not in a test). Until now
|
|
192
|
+
* `--commit-to` only ever appended to `led.items` and NEVER wrote `led.objective` — the ONLY field
|
|
193
|
+
* the Stop hook's forcing logic actually reads (`authorizedContinuationObjective`, above). Every
|
|
194
|
+
* test that ever proved forcing worked hand-constructed a valid objective directly into a fixture
|
|
195
|
+
* ledger; no real invocation took that path. Result: real `--commit-to` calls sat in a real ledger
|
|
196
|
+
* with zero effect for hours. Fixed at the source: `--commit-to` now writes a real, valid objective
|
|
197
|
+
* matching every field `authorizedContinuationObjective` requires, using the ONE session wildcard
|
|
198
|
+
* that function accepts (`sessionIds: ['*']`) — because this is a bare terminal invocation with no
|
|
199
|
+
* access to the session_id a future Stop event will carry; only a live Stop hook ever sees that.
|
|
200
|
+
* `worktreeIds`/`projectId` ARE knowable here (from cwd), so those are never wildcarded.
|
|
201
|
+
*/
|
|
202
|
+
const identity = continuationProjectIdentity(process.cwd());
|
|
203
|
+
if (text && identity) {
|
|
204
|
+
led.objective = {
|
|
205
|
+
schemaVersion: 1, kind: 'continuation-preferences', authoritative: false,
|
|
206
|
+
id: crypto.randomUUID(), text, at, state: 'active',
|
|
207
|
+
projectId: identity.projectId, worktreeIds: [identity.worktreeId], sessionIds: ['*'],
|
|
208
|
+
authorization: { kind: 'user', reference: 'commit-to-cli' },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
save(led);
|
|
189
212
|
console.log(`committed: ${text}`);
|
|
190
213
|
process.exit(0);
|
|
191
214
|
}
|
|
@@ -28,7 +28,13 @@ export function authorizedContinuationObjective(objective, input, identity) {
|
|
|
28
28
|
|| !text(objective.id) || !text(objective.text) || !Number.isFinite(Date.parse(objective.at))
|
|
29
29
|
|| objective.authorization?.kind !== 'user' || !text(objective.authorization.reference)
|
|
30
30
|
|| objective.projectId !== identity.projectId
|
|
31
|
-
|
|
31
|
+
// '*' is the ONLY session wildcard, and it exists for exactly one reason: `--commit-to` (the CLI
|
|
32
|
+
// a model actually runs to arm this gate) writes the objective from a bare terminal invocation,
|
|
33
|
+
// which has no access to the session_id a future Stop event will carry — only a live Stop hook
|
|
34
|
+
// ever sees that. Every OTHER writer must still name real session ids; a wildcard is never
|
|
35
|
+
// implied by omission, only by this exact literal.
|
|
36
|
+
|| !Array.isArray(objective.sessionIds)
|
|
37
|
+
|| !(objective.sessionIds.includes(input.session_id) || objective.sessionIds.includes('*'))
|
|
32
38
|
|| !Array.isArray(objective.worktreeIds) || !objective.worktreeIds.includes(identity.worktreeId)) return null;
|
|
33
39
|
return objective;
|
|
34
40
|
}
|