ruvnet-brain 4.5.16 → 4.5.17
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 +1 -1
- package/bin/install.mjs +1 -1
- package/config/practical-rule-catalog.json +111 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +63 -22
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +1 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/source-scope-receipt.mjs +9 -4
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
|
+
import { contextBlock, contextFrame, readContextFrame, emitOwnedContext } from './hook-context-budget.mjs';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
// resolveBash ONLY. `skipNoBash` is not a predicate — it is a one-time notice emitter that returns 0
|
|
23
24
|
// and WRITES TO STDERR, which on this hot path is the refusal channel: calling it would have injected
|
|
@@ -240,6 +241,36 @@ export function decide(verdicts) {
|
|
|
240
241
|
return { allow: false, refusals: refusals.map((v) => v.id), reason };
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
/** Policy advice is context, separate from the winning refusal and the speech channel. */
|
|
245
|
+
export function policyContext(results, speechOutput = '') {
|
|
246
|
+
const contexts = [];
|
|
247
|
+
for (const result of results) {
|
|
248
|
+
if (result.skipped) contexts.push(`Policy ${result.id} did not obtain a verdict (${result.skipped}${result.reason ? `: ${String(result.reason).slice(0, 4096)}` : ''}).`);
|
|
249
|
+
if (result.code !== ALLOW || !result.stdout?.trim()) continue;
|
|
250
|
+
let parsed; try { parsed = JSON.parse(result.stdout); } catch {}
|
|
251
|
+
const text = typeof parsed?.hookSpecificOutput?.additionalContext === 'string'
|
|
252
|
+
? parsed.hookSpecificOutput.additionalContext : result.stdout.trim();
|
|
253
|
+
if (text) contexts.push(text.slice(0, 4096));
|
|
254
|
+
}
|
|
255
|
+
if (process.env.RUVNET_HOOK_CONTEXT_BUDGET === '1') {
|
|
256
|
+
const speechFrame = readContextFrame(speechOutput);
|
|
257
|
+
const blocks = contexts.map((text) => contextBlock(text, { id: 'policy-diagnostic', critical: true }));
|
|
258
|
+
if (speechFrame) blocks.push(...speechFrame.blocks);
|
|
259
|
+
else if (speechOutput.trim()) { let value; try { value = JSON.parse(speechOutput); } catch {}
|
|
260
|
+
const text = value?.hookSpecificOutput?.additionalContext || speechOutput.trim();
|
|
261
|
+
blocks.push(contextBlock(text, { id: 'unframed-speech-preserved', critical: true })); }
|
|
262
|
+
if (!blocks.length) return '';
|
|
263
|
+
return emitOwnedContext(contextFrame('decision-gate', 'PreToolUse', blocks));
|
|
264
|
+
}
|
|
265
|
+
if (!contexts.length) return speechOutput;
|
|
266
|
+
let speech; try { speech = JSON.parse(speechOutput); } catch {}
|
|
267
|
+
const prior = typeof speech?.hookSpecificOutput?.additionalContext === 'string'
|
|
268
|
+
? speech.hookSpecificOutput.additionalContext : speechOutput.trim();
|
|
269
|
+
return JSON.stringify({ ...(speech && typeof speech === 'object' && !Array.isArray(speech) ? speech : {}),
|
|
270
|
+
hookSpecificOutput: { ...(speech?.hookSpecificOutput || {}), hookEventName: 'PreToolUse',
|
|
271
|
+
additionalContext: [...new Set([prior, ...contexts].filter(Boolean))].join('\n') } });
|
|
272
|
+
}
|
|
273
|
+
|
|
243
274
|
const firstLine = (s) => String(s || '').trim().split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
|
|
244
275
|
|
|
245
276
|
/** Payload accessors — tolerant, because a malformed payload must degrade to "no measurement". */
|
|
@@ -263,6 +294,8 @@ function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : '
|
|
|
263
294
|
if (isMain()) {
|
|
264
295
|
const started = Date.now();
|
|
265
296
|
const payload = normalizePayloadText(readPayload());
|
|
297
|
+
const shape = classifyPayload(payload);
|
|
298
|
+
const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
|
|
266
299
|
const selected = policiesFor(EVENT);
|
|
267
300
|
// An unknown event is not an occasion to refuse anything. Same rule as unprompted-runtime's
|
|
268
301
|
// "never speak on a guess", pointed at the other decision. 'write' is the only registered route
|
|
@@ -270,7 +303,7 @@ if (isMain()) {
|
|
|
270
303
|
if (!selected.length && EVENT !== 'write') process.exit(ALLOW);
|
|
271
304
|
|
|
272
305
|
const budgetMs = Number(process.env.RUVNET_DECISION_BUDGET_MS) || DEFAULT_BUDGET_MS;
|
|
273
|
-
const deadline = started + budgetMs;
|
|
306
|
+
const deadline = Math.min(started + budgetMs, Number(process.env.RUVNET_DECISION_DEADLINE) || Infinity);
|
|
274
307
|
// Resolved ONCE. On win32 resolveBash() can shell out to `where.exe`; four bash policies meant up
|
|
275
308
|
// to four of those per tool call, for an answer that cannot change mid-invocation.
|
|
276
309
|
BASH = resolveBash();
|
|
@@ -302,34 +335,34 @@ if (isMain()) {
|
|
|
302
335
|
|
|
303
336
|
const decision = decide(verdicts);
|
|
304
337
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// about to refuse. Order matters — resolving after recording would close the debt we just opened.
|
|
309
|
-
// Entirely best-effort: measurement may never affect the verdict, so it runs after `decide`.
|
|
310
|
-
// An oversize payload is cut mid-JSON (see TRANSPORT_CAP_BYTES): recover session/tool/path from its
|
|
311
|
-
// prefix so its refusals and allows are counted like any other.
|
|
312
|
-
const shape = classifyPayload(payload);
|
|
313
|
-
const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
|
|
338
|
+
// Measurement follows the FINAL gate verdict; it must never delay a blocking producer.
|
|
339
|
+
const measureFinalDecision = (allowed, policies = decision.refusals) => {
|
|
340
|
+
const metricDeadline = Math.min(deadline, Date.now() + 25);
|
|
314
341
|
try {
|
|
315
342
|
const key = shape.truncated
|
|
316
343
|
? actionKey(shape.tool, { file_path: shape.filePath })
|
|
317
344
|
: actionKey(payloadTool(payload), toolInput);
|
|
318
345
|
const ts = Date.now();
|
|
319
346
|
if (session && key) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
347
|
+
let observed = {}; try { observed = JSON.parse(payload); } catch { /* truncated payload: adapter/process scope */ }
|
|
348
|
+
const attribution = { host: observed.host || process.env.RUVNET_HOOK_HOST || 'claude',
|
|
349
|
+
project: observed.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd() };
|
|
350
|
+
sweepStale({ session, ts, deadlineAt: metricDeadline, nonBlocking: true }); // only proven observation age expires debt; foreign sessions are not abandoned
|
|
351
|
+
resolveOutcome({ session, key, allowed: allowed, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
|
|
352
|
+
if (!allowed) recordRefusal({ session, key, policies: policies, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
|
|
323
353
|
}
|
|
324
354
|
} catch { /* a ledger must never break a tool call */ }
|
|
325
355
|
|
|
356
|
+
};
|
|
357
|
+
|
|
326
358
|
reportSelfSkipped({ session, selfSkipped });
|
|
327
359
|
if (decision.allow && shape.truncated) {
|
|
328
360
|
// OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
|
|
329
361
|
try { appendOutcome({ kind: 'payload-oversize', event: EVENT, session, bytesSeen: shape.bytes, capBytes: TRANSPORT_CAP_BYTES, tool: shape.tool, filePath: shape.filePath, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
|
|
330
362
|
}
|
|
331
363
|
if (!decision.allow) {
|
|
332
|
-
|
|
364
|
+
measureFinalDecision(false);
|
|
365
|
+
reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
|
|
333
366
|
process.stderr.write(`${decision.reason}\n`);
|
|
334
367
|
process.exit(REFUSE);
|
|
335
368
|
}
|
|
@@ -347,15 +380,21 @@ if (isMain()) {
|
|
|
347
380
|
const speech = await runPolicy(SPEECH, payload, deadline, speechEventFor(EVENT), trace);
|
|
348
381
|
if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
|
|
349
382
|
if (speech.code === REFUSE) {
|
|
350
|
-
|
|
383
|
+
measureFinalDecision(false, [...decision.refusals, SPEECH.id]);
|
|
384
|
+
reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
|
|
351
385
|
process.stderr.write(`${(speech.stderr || '').trim()}\n`);
|
|
352
386
|
process.exit(REFUSE);
|
|
353
387
|
}
|
|
354
388
|
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
355
|
-
|
|
389
|
+
measureFinalDecision(true);
|
|
390
|
+
const context = policyContext(results, speech.stdout || '');
|
|
391
|
+
if (context) process.stdout.write(context);
|
|
356
392
|
process.exit(ALLOW);
|
|
357
393
|
}
|
|
358
394
|
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
395
|
+
measureFinalDecision(true);
|
|
396
|
+
const context = policyContext([...results, ...unconsulted.filter((id) => !results.some((r) => r.id === id && r.skipped)).map((id) => ({ id, skipped: 'budget' }))]);
|
|
397
|
+
if (context) process.stdout.write(context);
|
|
359
398
|
process.exit(ALLOW);
|
|
360
399
|
}
|
|
361
400
|
|
|
@@ -378,7 +417,7 @@ if (isMain()) {
|
|
|
378
417
|
* the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
|
|
379
418
|
* there is not noise to be tolerated — it is the defect, and the suite should go red for it.
|
|
380
419
|
*/
|
|
381
|
-
function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
|
|
420
|
+
function reportBudget({ session, unconsulted, trace, started, budgetMs, refused = false }) {
|
|
382
421
|
const elapsed = Date.now() - started;
|
|
383
422
|
if (process.env.RUVNET_DECISION_TRACE === '1') {
|
|
384
423
|
process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
|
|
@@ -388,8 +427,8 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
|
|
|
388
427
|
appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
|
|
389
428
|
} catch { /* a ledger must never break a tool call */ }
|
|
390
429
|
process.stderr.write(
|
|
391
|
-
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ALLOWED WITHOUT CONSULTING: `
|
|
392
|
-
+ `${unconsulted.join(', ')}. These policies did not vote; this allow is a timeout, not a verdict
|
|
430
|
+
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
|
|
431
|
+
+ `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
|
|
393
432
|
);
|
|
394
433
|
}
|
|
395
434
|
|
|
@@ -397,7 +436,7 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
|
|
|
397
436
|
function reportSelfSkipped({ session, selfSkipped }) {
|
|
398
437
|
for (const r of selfSkipped) {
|
|
399
438
|
try { appendOutcome({ kind: 'policy-skipped', event: EVENT, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
|
|
400
|
-
process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote;
|
|
439
|
+
process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
|
|
401
440
|
}
|
|
402
441
|
}
|
|
403
442
|
|
|
@@ -434,12 +473,13 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
|
|
|
434
473
|
const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(done(r)); };
|
|
435
474
|
let child;
|
|
436
475
|
try {
|
|
437
|
-
child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
|
|
476
|
+
child = spawn(cmd, args, { detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
|
|
438
477
|
} catch { return finish({ id: p.id, skipped: 'spawn' }); }
|
|
439
478
|
// SIGKILL, not SIGTERM: a bash policy that has spawned its own child (jq, node, ruflo) can sit in
|
|
440
479
|
// a TERM handler, and the host's own kill is what we are racing. The whole batch shares ONE
|
|
441
480
|
// deadline, so a single slow policy cancels only the time it actually consumed.
|
|
442
|
-
|
|
481
|
+
const retireTree = () => { try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch { /* already gone */ } };
|
|
482
|
+
timer = setTimeout(() => { retireTree(); finish({ id: p.id, skipped: 'budget' }); }, Math.max(1, left - 100));
|
|
443
483
|
let stdout = ''; let stderr = ''; let bytes = 0;
|
|
444
484
|
const MAX = 1 << 20; // same ceiling spawnSync's maxBuffer enforced; a policy is not a data source
|
|
445
485
|
child.stdout.on('data', (d) => { if (bytes < MAX) { stdout += d; bytes += d.length; } });
|
|
@@ -449,6 +489,7 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
|
|
|
449
489
|
// Unhandled, that error event would take the whole gate down and turn an allow into a hook error.
|
|
450
490
|
child.stdin.on('error', () => { /* the child did not want the payload; that is not a failure */ });
|
|
451
491
|
child.on('close', (code) => {
|
|
492
|
+
retireTree();
|
|
452
493
|
// A spawn failure, a timeout, or any code other than 0/2 is an ERROR — and an error here must
|
|
453
494
|
// never be mistaken for a refusal. That distinction is the one lesson-gate.mjs had to learn twice.
|
|
454
495
|
if (code === SKIPPED_SELF) return finish({ id: p.id, skipped: 'self', reason: firstLine(stderr) });
|
|
@@ -1,71 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Decision refusal observations, not repair-success proof. An allowed PreToolUse retry is an
|
|
3
|
+
* admission; no PostToolUse completion is observed here. Historical corrected records remain
|
|
4
|
+
* untouched and are labeled as legacy admissions in the report. Fresh foreign-session debts are
|
|
5
|
+
* retained. Only explicit session closure is abandonment; old observations may expire by age.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the bridged lessons — names exactly that failure: a stamp proves the ritual ran, not that its
|
|
9
|
-
* answer was obeyed.
|
|
10
|
-
*
|
|
11
|
-
* A system that cannot see its own outcomes cannot improve, and one that reports a number it cannot
|
|
12
|
-
* source is doing the fabrication this repo has a CI gate against.
|
|
13
|
-
*
|
|
14
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
* WHAT IS ACTUALLY OBSERVABLE, and this is the honest part.
|
|
16
|
-
*
|
|
17
|
-
* "Did the model obey an advisory?" is NOT observable from a hook. The advisory reaches the model's
|
|
18
|
-
* context and what it does next is unconstrained prose. Claiming to measure that would be the
|
|
19
|
-
* inflated-score failure mode.
|
|
20
|
-
*
|
|
21
|
-
* What IS observable, exactly, from the one gate that sees every Write/Edit/Bash: **what happened
|
|
22
|
-
* after a REFUSAL**. Three outcomes, mutually exclusive and jointly exhaustive:
|
|
23
|
-
*
|
|
24
|
-
* corrected the same target was attempted again and was ALLOWED — the reason landed, the user
|
|
25
|
-
* or model fixed the thing and proceeded. This is the outcome the gate exists to cause.
|
|
26
|
-
* repeated the same target was attempted again and refused again — the reason did NOT land.
|
|
27
|
-
* A guard that produces this is teaching nothing and is on its way to being switched off.
|
|
28
|
-
* abandoned never attempted again in this session. Ambiguous ON PURPOSE and never counted as a
|
|
29
|
-
* win: it is equally "they understood and stopped" and "they gave up and worked around
|
|
30
|
-
* us", and this ledger must not pick the flattering reading.
|
|
31
|
-
*
|
|
32
|
-
* THE ONE WAY TO FABRICATE THIS, named so a reviewer can check for it: record only `corrected`. The
|
|
33
|
-
* rate is corrected ÷ (corrected + repeated + abandoned), so a caller that forgets the other two
|
|
34
|
-
* reports a perfect score. The invariant is therefore NOT "record outcomes" but **every refusal
|
|
35
|
-
* produces exactly one record**, and `abandoned` is what an unresolved refusal becomes at session
|
|
36
|
-
* end. Same invariant, same reasoning, as advocacy-outcomes.mjs — one pattern, not two.
|
|
37
|
-
*
|
|
38
|
-
* SCOPE, stated rather than implied: this measures the BLOCKING path only. Advisory lessons are
|
|
39
|
-
* counted as `surfaced` for coverage and are NEVER scored, because their effect is not observable
|
|
40
|
-
* here. A coverage number that hides what it cannot see is the same lie as a truncated list.
|
|
41
|
-
*
|
|
42
|
-
* STORAGE: ~/.config/ruvnet-brain/, user-level, deliberately OUTSIDE ~/.cache/ruvnet-brain/ which
|
|
43
|
-
* `--update` replaces wholesale — an outcome destroyed by the next release never compounds.
|
|
44
|
-
* Append-only JSONL, bounded, node builtins only, and every write is best-effort: a ledger that
|
|
45
|
-
* could break a tool call would be worse than no ledger.
|
|
7
|
+
* The existing JSON/JSONL paths remain intact. Owned exclusive locks serialize pending snapshots
|
|
8
|
+
* and ledger appends; atomic/fsynced publication follows the repository's existing write protocol.
|
|
9
|
+
* Malformed, linked, busy or full state is unavailable, never replaced, truncated or written through.
|
|
10
|
+
* Logging remains best-effort and cannot alter the gate's verdict.
|
|
46
11
|
*/
|
|
47
12
|
import fs from 'node:fs';
|
|
48
13
|
import os from 'node:os';
|
|
49
14
|
import path from 'node:path';
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
50
16
|
|
|
51
|
-
export const CONFIG_ROOT = process.env.RUVNET_CONFIG_ROOT
|
|
52
|
-
|
|
53
|
-
export const
|
|
54
|
-
|
|
55
|
-
/** Pending refusals awaiting an outcome. Separate from the ledger so a resolve is one small write. */
|
|
56
|
-
export const PENDING = process.env.RUVNET_DECISION_PENDING
|
|
57
|
-
|| path.join(CONFIG_ROOT, 'decision-pending.json');
|
|
58
|
-
|
|
59
|
-
const MAX_LEDGER_BYTES = 1 << 20; // ~1MB, then the oldest half is dropped
|
|
17
|
+
export const CONFIG_ROOT = process.env.RUVNET_CONFIG_ROOT || path.join(os.homedir(), '.config', 'ruvnet-brain');
|
|
18
|
+
export const LEDGER = process.env.RUVNET_DECISION_LEDGER || path.join(CONFIG_ROOT, 'decision-outcomes.jsonl');
|
|
19
|
+
export const PENDING = process.env.RUVNET_DECISION_PENDING || path.join(CONFIG_ROOT, 'decision-pending.json');
|
|
20
|
+
const MAX_LEDGER_BYTES = 1 << 20;
|
|
60
21
|
const MAX_PENDING = 200;
|
|
22
|
+
const LOCK_WAIT_MS = 1000;
|
|
23
|
+
const TERMINAL = new Set(['admitted-retry', 'corrected', 'repeated', 'abandoned', 'expired']);
|
|
61
24
|
|
|
62
|
-
/**
|
|
63
|
-
* The identity of "the same action being retried".
|
|
64
|
-
*
|
|
65
|
-
* Deliberately COARSE: tool + target, never the full input. A model that fixes a refusal usually
|
|
66
|
-
* changes the content while keeping the target, and keying on content would score every correction
|
|
67
|
-
* as a brand-new action and make `repeated` unreachable — a metric that can only produce good news.
|
|
68
|
-
*/
|
|
69
25
|
export function actionKey(toolName, toolInput) {
|
|
70
26
|
const t = String(toolName || '').toLowerCase();
|
|
71
27
|
const i = toolInput || {};
|
|
@@ -76,156 +32,193 @@ export function actionKey(toolName, toolInput) {
|
|
|
76
32
|
return `${t}:${String(i.file_path || i.path || '').trim()}`;
|
|
77
33
|
}
|
|
78
34
|
|
|
79
|
-
const readJson = (file, fallback) => {
|
|
80
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
81
|
-
};
|
|
82
|
-
const writeJson = (file, value) => {
|
|
83
|
-
try {
|
|
84
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
85
|
-
fs.writeFileSync(file, JSON.stringify(value));
|
|
86
|
-
return true;
|
|
87
|
-
} catch { return false; }
|
|
88
|
-
};
|
|
89
35
|
|
|
90
|
-
|
|
91
|
-
export function append(record, file = LEDGER) {
|
|
36
|
+
function regular(file) {
|
|
92
37
|
try {
|
|
93
|
-
fs.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
38
|
+
const st = fs.lstatSync(file);
|
|
39
|
+
if (!st.isFile() || st.isSymbolicLink() || st.nlink > 1
|
|
40
|
+
|| (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw Error('unmanaged decision state');
|
|
41
|
+
if (st.size > MAX_LEDGER_BYTES) throw Error('decision state capacity exhausted');
|
|
42
|
+
return st;
|
|
43
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
44
|
+
}
|
|
45
|
+
function readPending(file) {
|
|
46
|
+
if (!regular(file)) return {};
|
|
47
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('pending state unavailable');
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function readLedger(file) {
|
|
52
|
+
if (!regular(file)) return [];
|
|
53
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => {
|
|
54
|
+
const row = JSON.parse(line);
|
|
55
|
+
if (row?.kind === 'refused' || TERMINAL.has(row?.kind)) {
|
|
56
|
+
if (!validDebt(row) || (row.host != null && typeof row.host !== 'string')
|
|
57
|
+
|| (row.projectId != null && !/^[a-f0-9]{64}$/.test(row.projectId))) throw Error('malformed decision observation');
|
|
58
|
+
if (row.kind === 'admitted-retry' && (typeof row.refusalId !== 'string' || !row.refusalId
|
|
59
|
+
|| row.evidence !== 'pretool-decision-observation' || row.repairVerified !== false)) throw Error('malformed admitted observation');
|
|
60
|
+
}
|
|
61
|
+
return row;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Same owned wx/token protocol as the existing grounding and continuity handlers. */
|
|
65
|
+
function locked(files, work, fallback, { deadlineAt = Infinity, nonBlocking = true } = {}) {
|
|
66
|
+
const paths = [...new Set(files)].sort(), held = [], token = randomUUID();
|
|
67
|
+
const owns = () => held.every(({ file }) => {
|
|
68
|
+
try { return !fs.lstatSync(file).isSymbolicLink() && fs.readFileSync(file, 'utf8') === token; } catch { return false; }
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
if (!(Number.isFinite(deadlineAt) || deadlineAt === Infinity) || Date.now() >= deadlineAt) return fallback;
|
|
72
|
+
const deadline = Math.min(Date.now() + LOCK_WAIT_MS, deadlineAt);
|
|
73
|
+
for (const target of paths) {
|
|
74
|
+
if (Date.now() >= deadline) return fallback;
|
|
75
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
76
|
+
regular(target);
|
|
77
|
+
const file = target + '.lock';
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
const fd = fs.openSync(file, 'wx', 0o600); held.push({ file, fd }); fs.writeSync(fd, token); break;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== 'EEXIST' || nonBlocking || Date.now() >= deadline) return fallback;
|
|
83
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
84
|
+
}
|
|
99
85
|
}
|
|
100
|
-
}
|
|
101
|
-
|
|
86
|
+
}
|
|
87
|
+
const permitted = () => Date.now() < deadline && owns();
|
|
88
|
+
if (!permitted()) return fallback;
|
|
89
|
+
return work(permitted);
|
|
90
|
+
} catch { return fallback; }
|
|
91
|
+
finally {
|
|
92
|
+
for (const { file, fd } of held.reverse()) {
|
|
93
|
+
try { fs.closeSync(fd); } catch {}
|
|
94
|
+
try { if (!fs.lstatSync(file).isSymbolicLink() && fs.readFileSync(file, 'utf8') === token) fs.unlinkSync(file); } catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function writePending(file, value, owns) {
|
|
99
|
+
const previous = regular(file), temp = `${file}.tmp-${randomUUID()}`;
|
|
100
|
+
const fd = fs.openSync(temp, 'wx', previous ? previous.mode & 0o777 : 0o600);
|
|
101
|
+
try {
|
|
102
|
+
const text = JSON.stringify(value);
|
|
103
|
+
if (fs.writeSync(fd, text) !== Buffer.byteLength(text)) throw Error('incomplete pending state write');
|
|
104
|
+
fs.fsyncSync(fd);
|
|
105
|
+
if (!owns()) throw Error('decision state lock changed');
|
|
106
|
+
fs.renameSync(temp, file);
|
|
107
|
+
} finally { fs.closeSync(fd); try { fs.unlinkSync(temp); } catch {} }
|
|
108
|
+
}
|
|
109
|
+
function appendLocked(record, file, owns) {
|
|
110
|
+
const st = regular(file), text = JSON.stringify(record) + '\n';
|
|
111
|
+
if ((st?.size || 0) + Buffer.byteLength(text) > MAX_LEDGER_BYTES || !owns()) throw Error('decision ledger unavailable');
|
|
112
|
+
const fd = fs.openSync(file, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
113
|
+
try {
|
|
114
|
+
if (fs.writeSync(fd, text) !== Buffer.byteLength(text)) throw Error('incomplete decision ledger write');
|
|
115
|
+
fs.fsyncSync(fd);
|
|
116
|
+
} finally { fs.closeSync(fd); }
|
|
117
|
+
}
|
|
118
|
+
export function append(record, file = LEDGER, budget = {}) {
|
|
119
|
+
return locked([file], owns => { appendLocked(record, file, owns); return true; }, false, budget);
|
|
120
|
+
}
|
|
121
|
+
const stateFiles = files => ({ pending: files.pending || PENDING, ledger: files.ledger || LEDGER });
|
|
122
|
+
const debtId = debt => debt.refusalId || createHash('sha256').update(JSON.stringify([debt.session, debt.key, debt.ts, debt.host || null, debt.projectId || null])).digest('hex');
|
|
123
|
+
const validDebt = value => value && typeof value.session === 'string' && typeof value.key === 'string'
|
|
124
|
+
&& value.session.length > 0 && value.key.length > 0 && Number.isFinite(value.ts) && Array.isArray(value.policies)
|
|
125
|
+
&& value.policies.every(policy => typeof policy === 'string');
|
|
126
|
+
function scopeOf(host, project) {
|
|
127
|
+
if (host !== undefined && host !== null && typeof host !== 'string') throw Error('host scope unavailable');
|
|
128
|
+
const projectId = project ? createHash('sha256').update(fs.realpathSync(project)).digest('hex') : null;
|
|
129
|
+
return { host: host || null, projectId };
|
|
130
|
+
}
|
|
131
|
+
const sameScope = (left, right) => (left.host || null) === (right.host || null) && (left.projectId || null) === (right.projectId || null);
|
|
132
|
+
const pendingId = debt => `${debt.session}\u0000${debt.key}${debt.host || debt.projectId ? `\u0000${debt.host || ''}\u0000${debt.projectId || ''}` : ''}`;
|
|
133
|
+
const terminals = rows => new Set(rows.filter(row => TERMINAL.has(row?.kind) && validDebt(row)).map(debtId));
|
|
134
|
+
function debts(pending, rows) {
|
|
135
|
+
const out = new Map(), closed = terminals(rows);
|
|
136
|
+
for (const value of Object.values(pending)) if (validDebt(value) && !closed.has(debtId(value))) out.set(debtId(value), value);
|
|
137
|
+
// Recover the crash window after a refused append was fsynced but before its pending rename.
|
|
138
|
+
for (const row of rows) if (row?.kind === 'refused' && validDebt(row) && !closed.has(debtId(row))) out.set(debtId(row), row);
|
|
139
|
+
return [...out.values()];
|
|
140
|
+
}
|
|
141
|
+
function transaction(files, callback, fallback, budget = {}) {
|
|
142
|
+
const f = stateFiles(files);
|
|
143
|
+
return locked([f.pending, f.ledger], owns => callback(f, readPending(f.pending), readLedger(f.ledger), owns), fallback, budget);
|
|
144
|
+
}
|
|
145
|
+
export function recordRefusal({ session, key, policies, ts, host, project, deadlineAt, nonBlocking }, files = {}) {
|
|
146
|
+
if (!validDebt({ session, key, policies, ts })) return false;
|
|
147
|
+
return transaction(files, (f, pending, rows, owns) => {
|
|
148
|
+
const scope = scopeOf(host, project), id = pendingId({ session, key, ...scope });
|
|
149
|
+
if (pending[id] && !validDebt(pending[id])) return false;
|
|
150
|
+
const open = debts(pending, rows);
|
|
151
|
+
const prior = open.find(value => value.session === session && value.key === key && sameScope(value, scope));
|
|
152
|
+
if (open.length >= MAX_PENDING && !prior) return false; // defer; never abandon another owner to make room
|
|
153
|
+
if (prior) appendLocked({ ...prior, kind: 'repeated', ts, refusalId: debtId(prior) }, f.ledger, owns);
|
|
154
|
+
const debt = { session, key, policies, ts, ...scope, refusalId: randomUUID() };
|
|
155
|
+
appendLocked({ ...debt, kind: 'refused' }, f.ledger, owns);
|
|
156
|
+
pending[id] = debt; writePending(f.pending, pending, owns);
|
|
102
157
|
return true;
|
|
103
|
-
}
|
|
158
|
+
}, false, { deadlineAt, nonBlocking });
|
|
104
159
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
append({ kind: 'refused', session, key, policies, ts }, ledger);
|
|
114
|
-
const pending = readJson(pendingFile, {});
|
|
115
|
-
pending[`${session}\u0000${key}`] = { session, key, policies, ts };
|
|
116
|
-
// Bound it: on overflow the OLDEST debts are resolved as `abandoned` rather than silently dropped,
|
|
117
|
-
// because a dropped debt is a miss that never appears in the denominator.
|
|
118
|
-
const entries = Object.entries(pending);
|
|
119
|
-
if (entries.length > MAX_PENDING) {
|
|
120
|
-
const sorted = entries.sort((a, b) => (a[1].ts || 0) - (b[1].ts || 0));
|
|
121
|
-
for (const [k, v] of sorted.slice(0, entries.length - MAX_PENDING)) {
|
|
122
|
-
append({ kind: 'abandoned', session: v.session, key: v.key, policies: v.policies, ts }, ledger);
|
|
123
|
-
delete pending[k];
|
|
160
|
+
export function resolve({ session, key, allowed, ts, host, project, deadlineAt, nonBlocking }, files = {}) {
|
|
161
|
+
if (typeof allowed !== 'boolean' || typeof session !== 'string' || typeof key !== 'string' || !Number.isFinite(ts)) return null;
|
|
162
|
+
return transaction(files, (f, pending, rows, owns) => {
|
|
163
|
+
const scope = scopeOf(host, project), id = pendingId({ session, key, ...scope });
|
|
164
|
+
const debt = debts(pending, rows).find(value => value.session === session && value.key === key && sameScope(value, scope));
|
|
165
|
+
if (!debt) {
|
|
166
|
+
if (validDebt(pending[id]) && terminals(rows).has(debtId(pending[id]))) { delete pending[id]; writePending(f.pending, pending, owns); }
|
|
167
|
+
return null;
|
|
124
168
|
}
|
|
125
|
-
|
|
126
|
-
|
|
169
|
+
const kind = allowed === true ? 'admitted-retry' : 'repeated';
|
|
170
|
+
appendLocked({ kind, session, key, ...scope, policies: debt.policies, ts, refusalId: debtId(debt), afterMs: ts - debt.ts,
|
|
171
|
+
evidence: 'pretool-decision-observation', repairVerified: false }, f.ledger, owns);
|
|
172
|
+
if (validDebt(pending[id])) delete pending[id];
|
|
173
|
+
writePending(f.pending, pending, owns);
|
|
174
|
+
return kind;
|
|
175
|
+
}, null, { deadlineAt, nonBlocking });
|
|
127
176
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (!debt) return null;
|
|
140
|
-
const kind = allowed ? 'corrected' : 'repeated';
|
|
141
|
-
append({ kind, session, key, policies: debt.policies, ts, afterMs: ts - (debt.ts || ts) }, ledger);
|
|
142
|
-
delete pending[id];
|
|
143
|
-
writeJson(pendingFile, pending);
|
|
144
|
-
return kind;
|
|
177
|
+
function closeDebts(files, ts, predicate, kind, budget = {}) {
|
|
178
|
+
return transaction(files, (f, pending, rows, owns) => {
|
|
179
|
+
const selected = debts(pending, rows).filter(predicate);
|
|
180
|
+
for (const debt of selected) {
|
|
181
|
+
appendLocked({ kind, session: debt.session, key: debt.key, host: debt.host || null, projectId: debt.projectId || null,
|
|
182
|
+
policies: debt.policies, ts, refusalId: debtId(debt) }, f.ledger, owns);
|
|
183
|
+
const id = pendingId(debt); if (validDebt(pending[id])) delete pending[id];
|
|
184
|
+
}
|
|
185
|
+
if (selected.length) writePending(f.pending, pending, owns);
|
|
186
|
+
return selected.length;
|
|
187
|
+
}, 0, budget);
|
|
145
188
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
* WITHOUT THIS THE METRIC IS A LIE BY OMISSION. A refusal the model simply walked away from is the
|
|
151
|
-
* single most likely outcome — and an unresolved debt sits in `open`, outside the denominator, so
|
|
152
|
-
* `correctedRate` would be computed only over the actions someone bothered to retry. That is the
|
|
153
|
-
* "record only the wins" fabrication this file's header names, arriving through the back door.
|
|
154
|
-
*
|
|
155
|
-
* Swept from the gate itself rather than a SessionEnd hook: SessionEnd is not guaranteed to fire (a
|
|
156
|
-
* crash, a kill, a compact all skip it), and this repo has already paid for a queue that only drained
|
|
157
|
-
* on a graceful exit — ADR-027's heartbeat flush, where 1,884 events accumulated over days. Activity
|
|
158
|
-
* is the trigger, not politeness.
|
|
159
|
-
*/
|
|
160
|
-
export function sweepStale({ session, ts, maxAgeMs = 6 * 60 * 60_000 }, files = {}) {
|
|
161
|
-
const pendingFile = files.pending || PENDING;
|
|
162
|
-
const ledger = files.ledger || LEDGER;
|
|
163
|
-
const pending = readJson(pendingFile, {});
|
|
164
|
-
let closed = 0;
|
|
165
|
-
for (const [k, v] of Object.entries(pending)) {
|
|
166
|
-
// A different session is over as far as this process can tell; the current session's debts stay
|
|
167
|
-
// open until they resolve or age out, because a retry three calls later is still a real outcome.
|
|
168
|
-
const stale = v.session !== session || (ts - (v.ts || ts)) > maxAgeMs;
|
|
169
|
-
if (!stale) continue;
|
|
170
|
-
append({ kind: 'abandoned', session: v.session, key: v.key, policies: v.policies, ts }, ledger);
|
|
171
|
-
delete pending[k];
|
|
172
|
-
closed += 1;
|
|
173
|
-
}
|
|
174
|
-
if (closed) writeJson(pendingFile, pending);
|
|
175
|
-
return closed;
|
|
189
|
+
/** A differing session is not evidence of abandonment; only proven observation age expires debt. */
|
|
190
|
+
export function sweepStale({ session, ts, maxAgeMs = 6 * 60 * 60_000, deadlineAt, nonBlocking }, files = {}) {
|
|
191
|
+
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0 || !Number.isFinite(ts)) return 0;
|
|
192
|
+
return closeDebts(files, ts, debt => ts - debt.ts > maxAgeMs, 'expired', { deadlineAt, nonBlocking });
|
|
176
193
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const pending = readJson(pendingFile, {});
|
|
183
|
-
let closed = 0;
|
|
184
|
-
for (const [k, v] of Object.entries(pending)) {
|
|
185
|
-
if (v.session !== session) continue;
|
|
186
|
-
append({ kind: 'abandoned', session, key: v.key, policies: v.policies, ts }, ledger);
|
|
187
|
-
delete pending[k];
|
|
188
|
-
closed += 1;
|
|
189
|
-
}
|
|
190
|
-
if (closed) writeJson(pendingFile, pending);
|
|
191
|
-
return closed;
|
|
194
|
+
/** Explicit session closure only affects that named session; not an automatic lifecycle delivery claim. */
|
|
195
|
+
export function abandonSession(session, ts, files = {}, scopeInput = {}) {
|
|
196
|
+
if (!Number.isFinite(ts)) return 0;
|
|
197
|
+
let scope; try { scope = scopeOf(scopeInput.host, scopeInput.project); } catch { return 0; }
|
|
198
|
+
return closeDebts(files, ts, debt => debt.session === session && sameScope(debt, scope), 'abandoned', scopeInput);
|
|
192
199
|
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The report. Every refusal must appear in exactly one terminal bucket, and `open` is stated
|
|
196
|
-
* separately so a half-finished session cannot inflate the rate by shrinking the denominator.
|
|
197
|
-
*/
|
|
198
200
|
export function report(files = {}) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
for (const p of r.policies || []) {
|
|
215
|
-
byPolicy[p] = byPolicy[p] || { corrected: 0, repeated: 0, abandoned: 0 };
|
|
216
|
-
byPolicy[p][r.kind] += 1;
|
|
201
|
+
const unavailable = { available: false, metricBasis: 'pretool-admission-only', admittedRetryRate: null,
|
|
202
|
+
correctedRate: null, repairSuccessRate: null, reason: 'decision measurement busy, full or unreadable' };
|
|
203
|
+
return transaction(files, (f, pending, rows) => {
|
|
204
|
+
const count = kind => rows.filter(row => row?.kind === kind).length;
|
|
205
|
+
const legacyCorrectedRecords = count('corrected');
|
|
206
|
+
const admittedRetries = count('admitted-retry') + legacyCorrectedRecords;
|
|
207
|
+
const repeated = count('repeated'), abandoned = count('abandoned'), expired = count('expired');
|
|
208
|
+
const resolved = admittedRetries + repeated + abandoned + expired, open = debts(pending, rows).length;
|
|
209
|
+
const byPolicy = Object.create(null);
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
if (!TERMINAL.has(row?.kind)) continue;
|
|
212
|
+
const field = ['corrected', 'admitted-retry'].includes(row.kind) ? 'admittedRetries' : row.kind;
|
|
213
|
+
for (const policy of Array.isArray(row.policies) ? row.policies : []) {
|
|
214
|
+
byPolicy[policy] ||= { admittedRetries: 0, repeated: 0, abandoned: 0, expired: 0 }; byPolicy[policy][field]++;
|
|
215
|
+
}
|
|
217
216
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
abandoned,
|
|
226
|
-
// NULL, not 0, when nothing has resolved. A rate printed as 0% on an empty ledger is a claim the
|
|
227
|
-
// guards are failing; the truth is that nothing has been measured yet.
|
|
228
|
-
correctedRate: resolved ? +(corrected / resolved).toFixed(3) : null,
|
|
229
|
-
byPolicy,
|
|
230
|
-
};
|
|
217
|
+
const atCapacity = open >= MAX_PENDING || (regular(f.ledger)?.size || 0) >= MAX_LEDGER_BYTES;
|
|
218
|
+
return { available: !atCapacity, completeCoverage: false, metricBasis: 'pretool-admission-only', refused: count('refused'), resolved, open,
|
|
219
|
+
admittedRetries, legacyCorrectedRecords, repeated, abandoned, expired, byPolicy, atCapacity,
|
|
220
|
+
foreignPendingRecords: Object.values(pending).filter(value => !validDebt(value)).length,
|
|
221
|
+
admittedRetryRate: !atCapacity && resolved ? +(admittedRetries / resolved).toFixed(3) : null,
|
|
222
|
+
correctedRate: null, repairSuccessRate: null };
|
|
223
|
+
}, unavailable);
|
|
231
224
|
}
|