ruvnet-brain 3.9.134-dev → 4.0.2
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 +14 -0
- package/README.md +5 -5
- package/bin/install.mjs +382 -36
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/kb/zip-extract.mjs +53 -14
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +14 -22
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/.claude-plugin/plugin.json +22 -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 +33 -0
- package/plugin/commands/rvbc.md +79 -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 +202 -0
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +56 -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 +182 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +517 -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 +173 -0
- package/plugin/scripts/learn-flush.mjs +155 -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/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +13 -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 +22 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +23 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- package/plugin/skills/whats-new/SKILL.md +22 -0
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/issue-watch.mjs — GitHub-issues SLA watcher.
|
|
3
|
+
//
|
|
4
|
+
// Stuart's mandate: no open issue on stuinfla/ruvnet-brain may sit >4h without a response, or an
|
|
5
|
+
// ntfy alert must reach his phone. "Response" = a comment from the repo owner (stuinfla) — a
|
|
6
|
+
// contributor's comment (see issue #12, commented by @sparkling) does NOT satisfy the SLA.
|
|
7
|
+
//
|
|
8
|
+
// 2026-07-24 incident, two rules born from it:
|
|
9
|
+
// 1. A BOT comment is not an owner response. issue-fix.mjs posts through the owner's gh auth, so
|
|
10
|
+
// its comments arrive as stuinfla — and four issues (#38/#39/#41/#42) sat 28h with ZERO pages
|
|
11
|
+
// because those bot comments satisfied the owner-comment check below. The fixer manufactured
|
|
12
|
+
// the exact signal that silences the watcher. Marked comments (BOT_MARKER) never count.
|
|
13
|
+
// 2. The breach alert is the ESCALATION channel, not the AWARENESS channel. Waiting 4h to say
|
|
14
|
+
// anything is how the maintainer learned about four issues from a GitHub email instead of a
|
|
15
|
+
// page. The watcher now pages ONCE, immediately, the first time it sees any open issue.
|
|
16
|
+
//
|
|
17
|
+
// Follows the house positive-confirmation pattern established 2026-07-13 (scripts/job-heartbeat.sh,
|
|
18
|
+
// scripts/nightly-watchdog.mjs, config/scheduled-jobs.json): this script is meant to run WRAPPED by
|
|
19
|
+
// job-heartbeat.sh from a launchd plist, so a crash still leaves a receipt and a failed run still
|
|
20
|
+
// pages the phone via the wrapper's own "SCHEDULED JOB FAILED" alert. This script's own exit code is
|
|
21
|
+
// therefore reserved for real execution failures (gh unreachable, bad JSON) — finding an SLA breach
|
|
22
|
+
// is the job working correctly, not a failure, so it always exits 0 on a clean run.
|
|
23
|
+
//
|
|
24
|
+
// Dedup: alerts for a given issue repeat at most once per SLA window (4h), tracked in a small state
|
|
25
|
+
// file, so a still-breaching issue doesn't re-page every hourly run.
|
|
26
|
+
//
|
|
27
|
+
// Usage:
|
|
28
|
+
// node scripts/issue-watch.mjs # check + alert on breaches
|
|
29
|
+
// node scripts/issue-watch.mjs --dry-run # check + print what WOULD be sent; no ntfy push, no state write
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const REPO = 'stuinfla/ruvnet-brain';
|
|
39
|
+
export const OWNER_LOGIN = 'stuinfla';
|
|
40
|
+
// Every comment the automation posts through the owner's gh auth begins with this prefix — the
|
|
41
|
+
// watcher's acknowledgment ("🤖 Automated acknowledgment …") and the fixer child's notes
|
|
42
|
+
// ("🤖 Automated issue-fix run …") both start with it. Spoof-safety: only comments AUTHORED BY
|
|
43
|
+
// the owner are checked against it, so a stranger opening their comment with the marker changes
|
|
44
|
+
// nothing — and the owner starting a personal reply with a robot emoji is not a realistic
|
|
45
|
+
// collision. (Generalized from the fixer-specific wording 2026-07-24 when the acknowledgment
|
|
46
|
+
// moved here.)
|
|
47
|
+
export const BOT_MARKER = '🤖 Automated';
|
|
48
|
+
const SLA_HOURS = 4;
|
|
49
|
+
const STATE_PATH = process.env.ISSUE_WATCH_STATE
|
|
50
|
+
|| path.join(os.homedir(), '.claude', 'ruvnet-brain', 'issue-watch-state.json');
|
|
51
|
+
// A compact, always-current snapshot the SessionStart hook surfaces (2026-07-17). ntfy alerts are
|
|
52
|
+
// easy to miss — issues stacked unseen for 29h precisely because the only channel was the phone.
|
|
53
|
+
// The session banner is a channel the maintainer cannot miss; this file is how it learns the count.
|
|
54
|
+
const STATUS_PATH = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
55
|
+
const GH_BIN = process.env.GH_BIN || 'gh';
|
|
56
|
+
|
|
57
|
+
function ghJson(args) {
|
|
58
|
+
// Retry ONCE on a transient network-shaped failure (2026-07-19, same class as issue-fix's 1am
|
|
59
|
+
// "TLS handshake timeout" page): 20s of patience absorbs a blip; a second failure still fails
|
|
60
|
+
// LOUD. Bounded, logged, never silent.
|
|
61
|
+
let lastErr;
|
|
62
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
63
|
+
const res = spawnSync(GH_BIN, args, { encoding: 'utf8' });
|
|
64
|
+
if (res.status === 0) return JSON.parse(res.stdout);
|
|
65
|
+
const err = (res.stderr || res.stdout || '').trim();
|
|
66
|
+
lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
|
|
67
|
+
const transient = /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable/i.test(err);
|
|
68
|
+
if (attempt === 1 && transient) {
|
|
69
|
+
console.error(`issue-watch: transient gh/network failure (${err.slice(0, 90)}) — retrying once in 20s`);
|
|
70
|
+
spawnSync('sleep', ['20']);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
throw lastErr;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve the ntfy topic the same way the rest of the repo does: env, then the machine-wide
|
|
79
|
+
* cache file, then the repo .env — see scripts/notify.sh / scripts/job-heartbeat.sh / scripts/nightly-watchdog.mjs. */
|
|
80
|
+
function resolveTopic() {
|
|
81
|
+
if (process.env.NTFY_TOPIC) return process.env.NTFY_TOPIC;
|
|
82
|
+
try {
|
|
83
|
+
const t = fs.readFileSync(path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ntfy-topic'), 'utf8').trim();
|
|
84
|
+
if (t) return t;
|
|
85
|
+
} catch { /* fall through */ }
|
|
86
|
+
try {
|
|
87
|
+
const env = fs.readFileSync(path.join(ROOT, '.env'), 'utf8');
|
|
88
|
+
const m = env.match(/^NTFY_TOPIC=(.*)$/m);
|
|
89
|
+
if (m) return m[1].trim();
|
|
90
|
+
} catch { /* fall through */ }
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotating_light' }) {
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`https://ntfy.sh/${topic}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { Title: title, Priority: priority, Tags: tags },
|
|
99
|
+
body,
|
|
100
|
+
});
|
|
101
|
+
return res.ok;
|
|
102
|
+
} catch {
|
|
103
|
+
return false; // alerting must never break the job — fail-silent, matching scripts/notify.sh
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function loadState() {
|
|
108
|
+
try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { return {}; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function saveState(state) {
|
|
112
|
+
fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
|
|
113
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fmtAge(hours) {
|
|
117
|
+
const h = Math.floor(hours);
|
|
118
|
+
const m = Math.round((hours - h) * 60);
|
|
119
|
+
return `${h}h ${m}m`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Judge one issue. Comments are re-fetched per-issue via `gh issue view` (not trusted from the
|
|
123
|
+
* list call) so owner-comment presence is computed off the authoritative per-issue payload. */
|
|
124
|
+
export function judgeIssue(issue, comments, now) {
|
|
125
|
+
const ageHours = (now - new Date(issue.createdAt).getTime()) / 3_600_000;
|
|
126
|
+
// An owner response is a HUMAN response: bot-marked comments never satisfy the SLA (see header,
|
|
127
|
+
// 2026-07-24 — the fixer's failure notes muted every page for four real issues).
|
|
128
|
+
const ownerComment = comments.find((c) => c.author?.login === OWNER_LOGIN
|
|
129
|
+
&& !String(c.body || '').trimStart().startsWith(BOT_MARKER));
|
|
130
|
+
const breach = ageHours > SLA_HOURS && !ownerComment;
|
|
131
|
+
return { ageHours, ownerComment: !!ownerComment, breach };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}) {
|
|
135
|
+
const issues = ghJson(['issue', 'list', '--repo', repo, '--state', 'open', '--json',
|
|
136
|
+
'number,title,createdAt,comments,updatedAt']);
|
|
137
|
+
|
|
138
|
+
const state = loadState();
|
|
139
|
+
const results = [];
|
|
140
|
+
const alertsSent = [];
|
|
141
|
+
|
|
142
|
+
for (const issue of issues) {
|
|
143
|
+
const detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']);
|
|
144
|
+
const { ageHours, ownerComment, breach } = judgeIssue(issue, detail.comments || [], now);
|
|
145
|
+
const url = `https://github.com/${repo}/issues/${issue.number}`;
|
|
146
|
+
const key = String(issue.number);
|
|
147
|
+
const last = state[key]?.lastAlertAt ? Date.parse(state[key].lastAlertAt) : null;
|
|
148
|
+
const dueForAlert = breach && (!last || (now - last) / 3_600_000 >= SLA_HOURS);
|
|
149
|
+
// First sighting → page once, immediately (rule 2 in the header). Delivery-derived like
|
|
150
|
+
// lastAlertAt: state is only written when the push actually went out, so a failed push
|
|
151
|
+
// retries next run instead of burying the sighting.
|
|
152
|
+
const firstSighting = !state[key];
|
|
153
|
+
|
|
154
|
+
results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url });
|
|
155
|
+
|
|
156
|
+
if (firstSighting) {
|
|
157
|
+
if (dryRun) {
|
|
158
|
+
alertsSent.push({ number: issue.number, sent: false, kind: 'new-issue', reason: 'dry-run' });
|
|
159
|
+
} else {
|
|
160
|
+
const topic = resolveTopic();
|
|
161
|
+
let sent = false;
|
|
162
|
+
if (topic) sent = await pushNtfy(topic, {
|
|
163
|
+
title: `New issue #${issue.number} (open ${fmtAge(ageHours)})`,
|
|
164
|
+
body: `${issue.title}\n${url}`,
|
|
165
|
+
priority: 'high', tags: 'new,eyes',
|
|
166
|
+
});
|
|
167
|
+
// THE ONE PUBLIC ACKNOWLEDGMENT (owner directive, 2026-07-24): tell the reporter we have
|
|
168
|
+
// it and it's being worked — once, warmly, with zero excuses and zero deadlines. After
|
|
169
|
+
// this, the thread's next post is a real fix, real findings, or the maintainer in person;
|
|
170
|
+
// failure-progress notes never appear anywhere ("we gave it 15 minutes and quit" reads as
|
|
171
|
+
// not caring — the opposite of the point). Carries BOT_MARKER so judgeIssue() can never
|
|
172
|
+
// mistake it for the owner responding. Best-effort like ntfy: a comment failure must not
|
|
173
|
+
// break the watch; unacked issues simply retry next run (ackAt is delivery-derived).
|
|
174
|
+
let ackAt = null;
|
|
175
|
+
const ackBody = `🤖 Automated acknowledgment — received and opened. The maintainer has been paged and this is being worked. The next update here will be a fix, findings, or the maintainer in person.`;
|
|
176
|
+
const ack = spawnSync(GH_BIN, ['issue', 'comment', String(issue.number), '--repo', repo, '--body', ackBody], { encoding: 'utf8' });
|
|
177
|
+
if (ack.status === 0) ackAt = new Date(now).toISOString();
|
|
178
|
+
if (sent || ackAt) state[key] = { firstSeenAt: new Date(now).toISOString(), ...(sent ? { newAlertAt: new Date(now).toISOString() } : {}), ...(ackAt ? { ackAt } : {}), title: issue.title, url };
|
|
179
|
+
alertsSent.push({ number: issue.number, sent, acked: Boolean(ackAt), kind: 'new-issue', reason: topic ? null : 'no ntfy topic configured' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (dueForAlert) {
|
|
184
|
+
const title = `SLA breach: issue #${issue.number}`;
|
|
185
|
+
const body = `${issue.title}\nopen ${fmtAge(ageHours)}, no response from ${OWNER_LOGIN}\n${url}`;
|
|
186
|
+
if (dryRun) {
|
|
187
|
+
alertsSent.push({ number: issue.number, sent: false, reason: 'dry-run' });
|
|
188
|
+
} else {
|
|
189
|
+
const topic = resolveTopic();
|
|
190
|
+
let sent = false;
|
|
191
|
+
if (topic) sent = await pushNtfy(topic, { title, body, priority: 'urgent', tags: 'rotating_light,warning' });
|
|
192
|
+
// DERIVED, not asserted (F5, 2026-07-18): lastAlertAt may only be written when the page was
|
|
193
|
+
// actually DELIVERED (sent===true). The old line stamped it unconditionally, so a breach whose
|
|
194
|
+
// push failed (ntfy down, no topic) was suppressed for the whole 4h cooldown — the alert ledger
|
|
195
|
+
// asserted a delivery it never verified. A failed attempt records itself as failed and the next
|
|
196
|
+
// hourly run retries; the ledger can no longer claim a page that didn't happen.
|
|
197
|
+
// Spread-merge, never overwrite: the record may already carry firstSeenAt/newAlertAt from
|
|
198
|
+
// the first-sighting page above — clobbering them would re-page "new" forever (the exact
|
|
199
|
+
// state-erasure class that broke issue-fix's comment dedup, 2026-07-24).
|
|
200
|
+
if (sent) state[key] = { ...(state[key] || {}), lastAlertAt: new Date(now).toISOString(), title: issue.title, url };
|
|
201
|
+
else state[key] = { ...(state[key] || {}), lastAttemptAt: new Date(now).toISOString(), sent: false, title: issue.title, url };
|
|
202
|
+
alertsSent.push({ number: issue.number, sent, kind: 'sla-breach', reason: topic ? null : 'no ntfy topic configured' });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!dryRun) saveState(state);
|
|
208
|
+
|
|
209
|
+
return { results, alertsSent, checkedAt: new Date(now).toISOString() };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function main() {
|
|
213
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
214
|
+
const asJson = process.argv.includes('--json');
|
|
215
|
+
|
|
216
|
+
let output;
|
|
217
|
+
try {
|
|
218
|
+
output = await run({ dryRun });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error(`issue-watch: FAILED — ${err.message}`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (asJson) {
|
|
225
|
+
console.log(JSON.stringify(output, null, 2));
|
|
226
|
+
} else {
|
|
227
|
+
console.log(`GitHub issue SLA watch — ${REPO} (SLA: ${SLA_HOURS}h to first owner (@${OWNER_LOGIN}) response)${dryRun ? ' [DRY-RUN]' : ''}\n`);
|
|
228
|
+
for (const r of output.results) {
|
|
229
|
+
const icon = r.breach ? '\u{1F534}' : '✅';
|
|
230
|
+
console.log(`${icon} #${r.number} ${r.title}`);
|
|
231
|
+
console.log(` age: ${fmtAge(r.ageHours)} · owner comment: ${r.ownerComment ? 'yes' : 'no'} · breach: ${r.breach ? 'YES' : 'no'}`);
|
|
232
|
+
if (r.firstSighting) {
|
|
233
|
+
const na = output.alertsSent.find((a) => a.number === r.number && a.kind === 'new-issue');
|
|
234
|
+
console.log(` 🆕 first sighting — ${dryRun ? '[DRY-RUN] would push new-issue page' : na?.sent ? 'new-issue page pushed' : `new-issue page NOT sent (${na?.reason})`}`);
|
|
235
|
+
}
|
|
236
|
+
if (r.dueForAlert) {
|
|
237
|
+
const alert = output.alertsSent.find((a) => a.number === r.number && a.kind === 'sla-breach');
|
|
238
|
+
console.log(` ${dryRun ? '[DRY-RUN] would push ntfy alert' : alert?.sent ? 'ntfy alert pushed' : `ntfy alert NOT sent (${alert?.reason})`}`);
|
|
239
|
+
} else if (r.breach) {
|
|
240
|
+
console.log(` already alerted within the last ${SLA_HOURS}h — not repeating`);
|
|
241
|
+
}
|
|
242
|
+
console.log('');
|
|
243
|
+
}
|
|
244
|
+
const breaches = output.results.filter((r) => r.breach).length;
|
|
245
|
+
console.log(breaches
|
|
246
|
+
? `${breaches} of ${output.results.length} open issue(s) are in SLA breach.`
|
|
247
|
+
: `All ${output.results.length} open issue(s) are within the ${SLA_HOURS}h SLA.`);
|
|
248
|
+
if (dryRun) console.log('(dry-run: no ntfy pushed, no state file written)');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Write the snapshot the SessionStart hook reads. Best-effort — a status-file failure must never
|
|
252
|
+
// fail the watcher (whose real job, alerting, already succeeded above).
|
|
253
|
+
if (!dryRun) {
|
|
254
|
+
try {
|
|
255
|
+
const issues = output.results.map((r) => ({
|
|
256
|
+
number: r.number, title: r.title, ageHours: Math.round(r.ageHours),
|
|
257
|
+
breach: !!r.breach, url: `https://github.com/${REPO}/issues/${r.number}`,
|
|
258
|
+
}));
|
|
259
|
+
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
|
|
260
|
+
fs.writeFileSync(STATUS_PATH, JSON.stringify({
|
|
261
|
+
at: new Date().toISOString(), repo: REPO,
|
|
262
|
+
open: issues.length, breaches: issues.filter((i) => i.breach).length, issues,
|
|
263
|
+
}, null, 2));
|
|
264
|
+
} catch { /* status file is best-effort; never break the watcher */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Finding a breach is the watcher doing its job — exit 0. But a due alert that FAILED TO DELIVER
|
|
268
|
+
// is an execution failure (Sol amendment to F5, 2026-07-18): this watcher's one real job is the
|
|
269
|
+
// page, and if the page didn't go out, "ok" would be asserted, not derived. Exit 1 so the
|
|
270
|
+
// heartbeat records the failure and the wrapper's own channel escalates — that duplicate-looking
|
|
271
|
+
// page IS the correct behavior when the primary page provably never left the building.
|
|
272
|
+
const undeliveredAlert = !dryRun && (output.alertsSent || []).some((a) => !a.sent && a.reason !== 'dry-run');
|
|
273
|
+
process.exit(undeliveredAlert ? 1 : 0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
## Fixed — and thank you
|
|
2
|
+
|
|
3
|
+
Updated: 2026-07-10
|
|
4
|
+
|
|
5
|
+
First: thank you for this report. It was precise, fully evidenced, and correct on every point — you quoted the exact claims, traced the exact files, and demonstrated the exact staleness on a clean install. Reports of this quality are rare and genuinely valuable; this one drove a same-day overhaul of the whole freshness pipeline. (Your PR #5 is under real review as well — separately, so it gets the attention it deserves.)
|
|
6
|
+
|
|
7
|
+
### What you reported, and what we changed
|
|
8
|
+
|
|
9
|
+
**1. "The nightly LaunchAgent is author-only, and even the upstream publish isn't nightly."**
|
|
10
|
+
Confirmed, with a root cause you couldn't have seen from outside: the author-side 3:15 AM job had been dying **every night** on `spawnSync gh ENOENT` — launchd's default `PATH` omits `/opt/homebrew/bin`, so the publish step never ran and `releases/latest` sat frozen while `main` advanced.
|
|
11
|
+
*Fixed in `2622c25`*: the LaunchAgent now exports its `PATH` explicitly; verified under the exact launchd environment before reload. **Proof this worked: `releases/latest` is now `__TAG__`, published `__PUB__` — by that nightly job, unattended.**
|
|
12
|
+
|
|
13
|
+
**2. "No end-user mechanism enables a per-user nightly."**
|
|
14
|
+
Correct — and the fix was closer than we realized: every bundle already ships a non-publishing updater (`forge-update.mjs --apply`: fetch canonical bundle → back up → extract → re-verify; loud failures, no partial clobber). What was missing was scheduling and honest wording. *Shipped in `158e888`*:
|
|
15
|
+
- `npx ruvnet-brain --update` — one-shot check + update of **your** install
|
|
16
|
+
- `npx ruvnet-brain --enable-nightly` — per-user LaunchAgent at 03:47, templated to **your** KB path, logging to `<kb>/update.log`; `--disable-nightly` reverts cleanly
|
|
17
|
+
- Linux/Windows: the cron pattern documented inside `forge-update.mjs` itself
|
|
18
|
+
- Off by default — nothing is ever scheduled on your machine unasked, and the installer now says exactly that instead of the old "auto-updating nightly? Just tell Claude" line, which promised something that never got scheduled. The README carries the same honest wording.
|
|
19
|
+
|
|
20
|
+
**3. "The deck's 'never goes stale / CURRENT · last rebuild · last night' doesn't match reality."**
|
|
21
|
+
You were right, and the deck itself was the problem — a stale deploy that should not have remained live. It hasn't been softened; it's been **retired**: `deck-six-liart.vercel.app` now permanently redirects (308) to the canonical explainer at https://isovision.ai/ruvnet-brain/, whose claims are the ones we deploy-verify.
|
|
22
|
+
|
|
23
|
+
### On your machine
|
|
24
|
+
```
|
|
25
|
+
npx ruvnet-brain --update # pulls the fresh bundle now
|
|
26
|
+
npx ruvnet-brain --enable-nightly # optional: 03:47 nightly, non-publishing, easy to disable
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If your install still lags after `--update`, please reopen — that would be a new bug and we want it.
|
|
30
|
+
|
|
31
|
+
Thanks again. The grounding idea only works if the ground is current; you made that true for everyone downstream.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// key-canary.mjs — live-probe every provider API key the environment delivers, and GONG on death.
|
|
3
|
+
//
|
|
4
|
+
// Built 2026-07-12, the day two dead keys were found by accident months after they died
|
|
5
|
+
// (ANTHROPIC_API_KEY and the machine-wide OPENAI_API_KEY, both 401). Stuart's mandate: staleness
|
|
6
|
+
// must be a monitoring problem, not a memory problem — "I'm just trying to find a way to not keep
|
|
7
|
+
// dealing with stale keys project to project."
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT DOES: for each known provider key PRESENT in the environment, makes the cheapest
|
|
10
|
+
// possible authenticated call (list-models class — $0, no tokens billed) and classifies:
|
|
11
|
+
// alive — HTTP 2xx
|
|
12
|
+
// DEAD — HTTP 401/403 (the key itself is rejected)
|
|
13
|
+
// unknown — network error / timeout / 5xx (NOT a key problem; never alarms)
|
|
14
|
+
// Absent keys are reported as absent (informational — many machines won't have every provider).
|
|
15
|
+
//
|
|
16
|
+
// GONG DISCIPLINE (same transition model as kb/brain-alarm.mjs): --notify sends ONE urgent push
|
|
17
|
+
// when a key TRANSITIONS to dead, and one recovery push when it comes back — not a re-alarm every
|
|
18
|
+
// night for a key you already know about (state: ~/.claude/metaharness/key-canary-state.json).
|
|
19
|
+
// Exit code: 1 if any key is DEAD (so wrappers/CI can react), else 0.
|
|
20
|
+
//
|
|
21
|
+
// RUN IT THROUGH THE REAL DELIVERY CHAIN: `zsh -lc 'node scripts/key-canary.mjs'` — a login shell
|
|
22
|
+
// sources ~/.zshrc -> env.global + the openclaw SOPS secrets, so the canary tests the exact env
|
|
23
|
+
// every real shell and script inherits, not a hand-fed copy. Never prints a key value.
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import { execFileSync } from 'node:child_process';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const STATE = path.join(os.homedir(), '.claude', 'metaharness', 'key-canary-state.json');
|
|
32
|
+
const NOTIFY = process.argv.includes('--notify');
|
|
33
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
|
|
35
|
+
const PROBES = [
|
|
36
|
+
{ env: 'ANTHROPIC_API_KEY', provider: 'Anthropic', url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) },
|
|
37
|
+
{ env: 'OPENAI_API_KEY', provider: 'OpenAI', url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
|
38
|
+
{ env: 'OPENROUTER_API_KEY', provider: 'OpenRouter', url: 'https://openrouter.ai/api/v1/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
|
39
|
+
{ env: 'GOOGLE_API_KEY', provider: 'Google/Gemini', url: null, headers: null }, // key goes in query string, built below
|
|
40
|
+
{ env: 'GEMINI_API_KEY', provider: 'Google/Gemini (GEMINI_API_KEY)', url: null, headers: null },
|
|
41
|
+
{ env: 'XAI_API_KEY', provider: 'xAI', url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
async function probe(p, key) {
|
|
45
|
+
const url = p.url || `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`;
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(url, { headers: p.headers ? p.headers(key) : {}, signal: AbortSignal.timeout(12000) });
|
|
48
|
+
if (res.ok) return 'alive';
|
|
49
|
+
if (res.status === 401 || res.status === 403) return 'DEAD';
|
|
50
|
+
return `unknown(HTTP ${res.status})`;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return `unknown(${e.name === 'TimeoutError' ? 'timeout' : 'network'})`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function notify(title, message, priority, tags) {
|
|
57
|
+
try {
|
|
58
|
+
execFileSync('sh', [path.join(REPO, 'scripts', 'notify.sh'), title, message, priority, tags], { cwd: REPO, stdio: 'ignore', timeout: 15000 });
|
|
59
|
+
} catch { /* notification failure must not break the canary */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prev = (() => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return {}; } })();
|
|
63
|
+
const now = {};
|
|
64
|
+
let anyDead = false;
|
|
65
|
+
|
|
66
|
+
for (const p of PROBES) {
|
|
67
|
+
const key = process.env[p.env];
|
|
68
|
+
if (!key) { console.log(` ${p.env.padEnd(20)} absent`); continue; }
|
|
69
|
+
const status = await probe(p, key);
|
|
70
|
+
now[p.env] = { status, ts: new Date().toISOString() };
|
|
71
|
+
const mark = status === 'alive' ? '✅' : status === 'DEAD' ? '🚨' : '❓';
|
|
72
|
+
console.log(` ${p.env.padEnd(20)} ${mark} ${status} (${p.provider}, key len ${key.length})`);
|
|
73
|
+
if (status === 'DEAD') {
|
|
74
|
+
anyDead = true;
|
|
75
|
+
if (NOTIFY && prev[p.env]?.status !== 'DEAD') {
|
|
76
|
+
notify(`🚨 ${p.provider} API key is DEAD`,
|
|
77
|
+
`${p.env} was rejected (401/403) by ${p.provider} just now. Every script using it is silently failing. `
|
|
78
|
+
+ `Rotate it: edit ~/Code/openclaw-stack/secrets.env, then run secrets-sync.sh seal.`,
|
|
79
|
+
'urgent', 'rotating_light,key');
|
|
80
|
+
}
|
|
81
|
+
} else if (status === 'alive' && NOTIFY && prev[p.env]?.status === 'DEAD') {
|
|
82
|
+
notify(`✅ ${p.provider} API key recovered`, `${p.env} is working again.`, 'default', 'white_check_mark,key');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(path.dirname(STATE), { recursive: true });
|
|
88
|
+
fs.writeFileSync(STATE, JSON.stringify(now, null, 2) + '\n');
|
|
89
|
+
} catch { /* state persistence is best-effort */ }
|
|
90
|
+
|
|
91
|
+
process.exit(anyDead ? 1 : 0);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* latency-to-surface.mjs — the metric ADR-028 calls "the single best summary metric", finally measured.
|
|
4
|
+
*
|
|
5
|
+
* ADR-028:103 defines it: "time between a capability becoming dormant and the user being told."
|
|
6
|
+
* Target "hours, not weeks", against a 21-day baseline that the ADR says "this whole project exists
|
|
7
|
+
* to destroy." An independent grader on 2026-07-24 charged -6 against the Proactive pillar for it,
|
|
8
|
+
* with a fair description: the 21-day baseline was still prose. Nothing recorded when a capability
|
|
9
|
+
* went dormant, so nothing could subtract.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT COULD NOT BE COMPUTED BEFORE, and what actually had to be built. The advocacy ledger already
|
|
12
|
+
* records when we SPOKE (`action:'offered'`, with an `at`). That is one end of the subtraction. The
|
|
13
|
+
* other end — when the capability BECAME dormant — existed nowhere, because the registry is a pure
|
|
14
|
+
* detector: it reports the state it observes right now and keeps no history. A detector with no
|
|
15
|
+
* memory can say "this is off"; it can never say "this has been off since Tuesday."
|
|
16
|
+
*
|
|
17
|
+
* So this file adds the missing half: an append-only log of capability state TRANSITIONS.
|
|
18
|
+
*
|
|
19
|
+
* ONLY TRANSITIONS, and that is a correctness decision rather than a disk-space one. If every
|
|
20
|
+
* observation were appended, "when did it become dormant" would depend on how often the console
|
|
21
|
+
* happened to be opened — a capability observed hourly would look freshly dormant, and the same
|
|
22
|
+
* capability observed weekly would look dormant for a week, from identical facts. Recording only
|
|
23
|
+
* changes makes the onset a property OF THE CAPABILITY rather than of our sampling schedule.
|
|
24
|
+
*
|
|
25
|
+
* WHAT COUNTS AS DORMANT — off and idle only.
|
|
26
|
+
* off → present and not running. Dormant.
|
|
27
|
+
* idle → set up, proven, nothing calling it. Dormant, and the state this product exists for.
|
|
28
|
+
* absent → NOT dormant. It was never installed; there is nothing lying unused.
|
|
29
|
+
* unknown → NOT dormant. We could not establish the state, and "we could not tell" must never be
|
|
30
|
+
* silently converted into "it is off" — that is the fabrication this repo's registry
|
|
31
|
+
* rule exists to prevent, and it would inflate the metric with invented dormancy.
|
|
32
|
+
*
|
|
33
|
+
* THE HONEST NULL. With no history, every latency is `null`, never `0`. A fresh install has not
|
|
34
|
+
* achieved instant surfacing; it has no measurement at all, and the difference is the whole
|
|
35
|
+
* difference between this product and one that lies. `summarize()` reports `measured: 0` in that
|
|
36
|
+
* case and every caller must render it as "not measured yet".
|
|
37
|
+
*
|
|
38
|
+
* THE NUMBER THAT MATTERS MOST is not the average of what we surfaced — it is `stillDark`: things
|
|
39
|
+
* dormant right now that we have NEVER told the user about, with their clocks still running. A
|
|
40
|
+
* project that only averages its successes reports a beautiful latency while a capability quietly
|
|
41
|
+
* rots. Those are counted separately and never folded into the mean.
|
|
42
|
+
*/
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import os from 'node:os';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
|
|
47
|
+
const HOME = os.homedir();
|
|
48
|
+
|
|
49
|
+
export const STATE_LOG_PATH = process.env.RUVNET_CAPABILITY_STATE_LOG
|
|
50
|
+
|| path.join(HOME, '.config', 'ruvnet-brain', 'capability-states.jsonl');
|
|
51
|
+
|
|
52
|
+
/** The states that mean "you have this, and it is not doing anything." See the header. */
|
|
53
|
+
export const DORMANT = new Set(['off', 'idle']);
|
|
54
|
+
|
|
55
|
+
const MAX_KEY = 120;
|
|
56
|
+
const MAX_LINE = 512;
|
|
57
|
+
|
|
58
|
+
/** Same never-throws contract as the advocacy ledger: an unreadable log degrades to "no history". */
|
|
59
|
+
export function loadStateLog(file = STATE_LOG_PATH) {
|
|
60
|
+
let raw;
|
|
61
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const line of raw.split('\n')) {
|
|
64
|
+
const s = line.trim();
|
|
65
|
+
if (!s) continue;
|
|
66
|
+
let r;
|
|
67
|
+
try { r = JSON.parse(s); } catch { continue; }
|
|
68
|
+
if (!r || typeof r.key !== 'string' || typeof r.state !== 'string') continue;
|
|
69
|
+
if (!Number.isFinite(Date.parse(r.at))) continue;
|
|
70
|
+
out.push(r);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The most recent recorded state per key — what a new observation is compared against. */
|
|
76
|
+
function lastStates(log) {
|
|
77
|
+
const last = new Map();
|
|
78
|
+
for (const r of log) last.set(r.key, r); // log is append-ordered
|
|
79
|
+
return last;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Record an observation, writing ONLY the keys whose state actually changed.
|
|
84
|
+
*
|
|
85
|
+
* Returns the transitions written, so a caller can act on them (and so a test can assert that a
|
|
86
|
+
* repeated identical observation writes nothing — the property the whole metric depends on).
|
|
87
|
+
*/
|
|
88
|
+
export function recordObservation(rows, { file = STATE_LOG_PATH, at = new Date() } = {}) {
|
|
89
|
+
const list = (Array.isArray(rows) ? rows : []).filter((r) => r && typeof r.key === 'string' && typeof r.state === 'string');
|
|
90
|
+
if (!list.length) return [];
|
|
91
|
+
|
|
92
|
+
const last = lastStates(loadStateLog(file));
|
|
93
|
+
const iso = (at instanceof Date && !Number.isNaN(at.getTime())) ? at.toISOString() : new Date().toISOString();
|
|
94
|
+
|
|
95
|
+
const transitions = [];
|
|
96
|
+
for (const r of list) {
|
|
97
|
+
const prev = last.get(r.key);
|
|
98
|
+
if (prev && prev.state === r.state) continue; // unchanged — the sampling schedule must not leak in
|
|
99
|
+
transitions.push({
|
|
100
|
+
v: 1,
|
|
101
|
+
key: r.key.slice(0, MAX_KEY),
|
|
102
|
+
state: r.state,
|
|
103
|
+
from: prev ? prev.state : null, // null = first time we ever saw this capability
|
|
104
|
+
at: iso,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (!transitions.length) return [];
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
111
|
+
const payload = transitions.map((t) => JSON.stringify(t)).join('\n') + '\n';
|
|
112
|
+
if (payload.length > MAX_LINE * transitions.length * 2) return []; // absurd input: refuse rather than corrupt
|
|
113
|
+
const fd = fs.openSync(file, 'a');
|
|
114
|
+
try { fs.writeSync(fd, payload); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
115
|
+
} catch { return []; } // an unwritable log must never break the console; it degrades to no history
|
|
116
|
+
|
|
117
|
+
return transitions;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* When did this capability most recently BECOME dormant, and has it left dormancy since?
|
|
122
|
+
* Returns the ISO instant of the latest entry into a dormant state that it is still in, else null.
|
|
123
|
+
*/
|
|
124
|
+
function currentDormancyOnset(entries) {
|
|
125
|
+
let onset = null;
|
|
126
|
+
for (const e of entries) {
|
|
127
|
+
if (DORMANT.has(e.state)) { if (onset === null) onset = e.at; }
|
|
128
|
+
else onset = null; // left dormancy (on / absent / unknown) — any earlier clock is void
|
|
129
|
+
}
|
|
130
|
+
return onset;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Join the state log against the advocacy ledger and compute the metric.
|
|
135
|
+
*
|
|
136
|
+
* `outcomes` is the advocacy ledger's rows (loadOutcomes()). Only `action:'offered'` counts as
|
|
137
|
+
* "the user was told" — an apply or a dismissal is a REPLY to having been told, and using it would
|
|
138
|
+
* measure the user's reaction time rather than ours.
|
|
139
|
+
*/
|
|
140
|
+
export function computeLatencies({ stateLog = loadStateLog(), outcomes = [], now = Date.now() } = {}) {
|
|
141
|
+
const byKey = new Map();
|
|
142
|
+
for (const e of stateLog) {
|
|
143
|
+
if (!byKey.has(e.key)) byKey.set(e.key, []);
|
|
144
|
+
byKey.get(e.key).push(e);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const toldAt = new Map(); // key → earliest 'offered' instant
|
|
148
|
+
for (const o of outcomes) {
|
|
149
|
+
if (!o || o.action !== 'offered' || typeof o.id !== 'string') continue;
|
|
150
|
+
const t = Date.parse(o.at);
|
|
151
|
+
if (!Number.isFinite(t)) continue;
|
|
152
|
+
const prev = toldAt.get(o.id);
|
|
153
|
+
if (prev === undefined || t < prev) toldAt.set(o.id, t);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const results = [];
|
|
157
|
+
for (const [key, entries] of byKey) {
|
|
158
|
+
const onsetIso = currentDormancyOnset(entries);
|
|
159
|
+
if (onsetIso === null) continue; // not dormant right now — nothing to measure
|
|
160
|
+
const onset = Date.parse(onsetIso);
|
|
161
|
+
if (!Number.isFinite(onset)) continue;
|
|
162
|
+
|
|
163
|
+
// Only an offer made AT OR AFTER this dormancy began counts. An offer from a previous dormant
|
|
164
|
+
// spell says nothing about whether we surfaced THIS one, and crediting it would let a single old
|
|
165
|
+
// notification make every future lapse look instantly surfaced.
|
|
166
|
+
const told = toldAt.get(key);
|
|
167
|
+
const surfaced = Number.isFinite(told) && told >= onset;
|
|
168
|
+
|
|
169
|
+
results.push({
|
|
170
|
+
key,
|
|
171
|
+
dormantSince: onsetIso,
|
|
172
|
+
surfaced,
|
|
173
|
+
latencyMs: surfaced ? told - onset : null,
|
|
174
|
+
darkMs: surfaced ? null : Math.max(0, now - onset),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return results;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The reportable rollup. Everything here is `null` rather than `0` when unmeasured — see the header.
|
|
182
|
+
*/
|
|
183
|
+
export function summarize(rows) {
|
|
184
|
+
const measuredRows = rows.filter((r) => r.surfaced && Number.isFinite(r.latencyMs));
|
|
185
|
+
const dark = rows.filter((r) => !r.surfaced);
|
|
186
|
+
const lat = measuredRows.map((r) => r.latencyMs).sort((a, b) => a - b);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
dormantNow: rows.length,
|
|
190
|
+
measured: lat.length,
|
|
191
|
+
medianMs: lat.length ? lat[Math.floor((lat.length - 1) / 2)] : null,
|
|
192
|
+
worstMs: lat.length ? lat[lat.length - 1] : null,
|
|
193
|
+
// The number that matters most: dormant, never surfaced, clock running. Never averaged in.
|
|
194
|
+
stillDark: dark.length,
|
|
195
|
+
longestDarkMs: dark.length ? Math.max(...dark.map((r) => r.darkMs ?? 0)) : null,
|
|
196
|
+
baselineDays: 21, // ADR-028:103 — the number this project exists to destroy
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function humanMs(ms) {
|
|
201
|
+
if (ms === null || ms === undefined || !Number.isFinite(ms)) return 'not measured';
|
|
202
|
+
const h = ms / 3_600_000;
|
|
203
|
+
if (h < 1) return `${Math.max(1, Math.round(ms / 60_000))}m`;
|
|
204
|
+
if (h < 48) return `${h < 10 ? h.toFixed(1) : Math.round(h)}h`;
|
|
205
|
+
return `${(h / 24).toFixed(1)}d`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── CLI ────────────────────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
210
|
+
const { loadOutcomes } = await import('./advocacy-outcomes.mjs');
|
|
211
|
+
const rows = computeLatencies({ outcomes: loadOutcomes() });
|
|
212
|
+
const s = summarize(rows);
|
|
213
|
+
|
|
214
|
+
if (process.argv.includes('--json')) {
|
|
215
|
+
console.log(JSON.stringify({ summary: s, rows }, null, 2));
|
|
216
|
+
} else {
|
|
217
|
+
console.log('latency-to-surface — ADR-028\'s "single best summary metric"');
|
|
218
|
+
console.log(` baseline to beat : ${s.baselineDays}d`);
|
|
219
|
+
console.log(` dormant now : ${s.dormantNow}`);
|
|
220
|
+
if (!s.measured && !s.stillDark) {
|
|
221
|
+
console.log(' measured : nothing yet — no capability has gone dormant since logging began.');
|
|
222
|
+
console.log(' (This is "no data", NOT "instant". The distinction is the product.)');
|
|
223
|
+
} else {
|
|
224
|
+
console.log(` median latency : ${humanMs(s.medianMs)} (from ${s.measured} surfaced)`);
|
|
225
|
+
console.log(` worst latency : ${humanMs(s.worstMs)}`);
|
|
226
|
+
if (s.stillDark) {
|
|
227
|
+
console.log(` STILL DARK : ${s.stillDark} dormant and never surfaced — longest ${humanMs(s.longestDarkMs)} and counting`);
|
|
228
|
+
console.log(' These are excluded from the median on purpose: averaging only');
|
|
229
|
+
console.log(' the successes is how a rotting capability hides behind a good number.');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|