ruvnet-brain 4.0.1 → 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 +1 -0
- package/README.md +4 -4
- package/bin/install.mjs +100 -5
- 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/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +8 -22
- package/plugin/.claude-plugin/marketplace.json +1 -0
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +2 -2
- package/plugin/commands/configure.md +3 -2
- package/plugin/commands/rvbc.md +4 -3
- package/plugin/commands/rvcb.md +2 -2
- package/plugin/hooks/hooks.json +1 -2
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +21 -0
- package/plugin/scripts/detach.mjs +14 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -6
- package/plugin/scripts/hook-shim.mjs +7 -7
- package/plugin/scripts/learn-capture.sh +22 -3
- package/plugin/scripts/learn-flush.mjs +21 -4
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +3 -858
- package/plugin/skills/brain-console/SKILL.md +4 -2
- 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 +5 -1
- package/plugin/skills/rvbc/SKILL.md +9 -6
- 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
- package/plugin/scripts/finalize-token-meter.mjs +0 -25
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/agentdb-context.mjs — AGENTDB IS THE FIRST-PARTY KEEPER OF CONTEXT.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/agentdb-context.mjs --session-start full recent context, for the SessionStart hook
|
|
5
|
+
// node scripts/agentdb-context.mjs --window 48 last N hours, FULL text of every entry
|
|
6
|
+
// node scripts/agentdb-context.mjs --grep "latency" full text of every entry matching
|
|
7
|
+
// node scripts/agentdb-context.mjs --key <key> one entry, complete
|
|
8
|
+
//
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// WHY THIS EXISTS — the owner, 2026-07-27, after the third time in one day:
|
|
11
|
+
//
|
|
12
|
+
// "I am tired of you going 'oh, I checked and it was there', but I didn't bother to read it, or I
|
|
13
|
+
// looked at one line even though there were 10,000 words. Whatever you need to do to make AgentDB
|
|
14
|
+
// the first-party keeper of all the knowledge you rely on and you go to all the time, deeply,
|
|
15
|
+
// completely, fully, totally."
|
|
16
|
+
//
|
|
17
|
+
// THE THREE FAILURES THIS CLOSES, all on 2026-07-27, all the same shape:
|
|
18
|
+
// 1. Told him Agentic-QE "did not run today" — from ONE `ls` of .agentic-qe/. It HAD run. AgentDB
|
|
19
|
+
// and the session transcripts both held the record; neither was opened.
|
|
20
|
+
// 2. Re-derived the search-latency root cause from scratch at 19:00, having already measured and
|
|
21
|
+
// WRITTEN it at 11:50 (`brain-search-latency-rootcause`). Two hours of duplicated work because
|
|
22
|
+
// a crash ate the context and nothing reloaded it.
|
|
23
|
+
// 3. Reported "4 branches unmerged" from a checkpoint, when one had already landed as PR #50.
|
|
24
|
+
//
|
|
25
|
+
// Every one of those was a SKIM: a directory listing, a key name, a one-line summary — never the
|
|
26
|
+
// 1,824-byte entry that held the answer. So the fix is NOT another instruction telling me to read
|
|
27
|
+
// carefully. Instructions decay; today proved it three times before dinner. The fix is to put the
|
|
28
|
+
// FULL TEXT in the context window automatically, so there is nothing left to skim.
|
|
29
|
+
//
|
|
30
|
+
// DESIGN LAW (ADR-055): substance, never ceremony. A recall that records "a lookup happened" is the
|
|
31
|
+
// grounding-stamp failure in a new costume — "The Brain did its job. I ignored it." This prints the
|
|
32
|
+
// CONTENT. The test of success is not that it ran; it is that the answer was already on screen.
|
|
33
|
+
//
|
|
34
|
+
// NO SILENT CAPS. There is a byte budget, because a context window is finite. When it binds, this
|
|
35
|
+
// says SO, out loud, with the exact count of what was withheld and the command to read it. A quiet
|
|
36
|
+
// truncation reads as "that's all there is", which is the same lie one layer down.
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { spawnSync } from 'node:child_process';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
|
|
43
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
export const REPO_ROOT = path.resolve(HERE, '..');
|
|
45
|
+
|
|
46
|
+
// Budget. Generous on purpose: the failure mode being fixed is starvation, not excess. ~48KB is a
|
|
47
|
+
// small fraction of the window and buys the entire recent history at FULL fidelity.
|
|
48
|
+
const DEFAULT_BUDGET = 48_000;
|
|
49
|
+
|
|
50
|
+
// Entry classes, most load-bearing first. Ordering matters: when the budget binds, what survives
|
|
51
|
+
// should be the findings and the negatives, not the routine checkpoints.
|
|
52
|
+
const PRIORITY = [
|
|
53
|
+
{ re: /rootcause|root-cause|ROOT-CAUSE/i, label: 'ROOT CAUSE', rank: 0 },
|
|
54
|
+
{ re: /^NORTH-STAR|north-star/i, label: 'NORTH STAR', rank: 0 },
|
|
55
|
+
{ re: /lesson-/i, label: 'LESSON', rank: 1 },
|
|
56
|
+
{ re: /project-state-current/i, label: 'CHECKPOINT', rank: 2 },
|
|
57
|
+
{ re: /session-(precompact|sessionend)/i, label: 'SESSION SNAPSHOT', rank: 3 },
|
|
58
|
+
];
|
|
59
|
+
function classify(key) {
|
|
60
|
+
for (const p of PRIORITY) if (p.re.test(key)) return p;
|
|
61
|
+
return { label: 'NOTE', rank: 1 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function dbPath(root = REPO_ROOT) { return path.join(root, '.swarm', 'memory.db'); }
|
|
65
|
+
|
|
66
|
+
// -json, never a delimiter. `content` is multi-line prose containing every punctuation mark there
|
|
67
|
+
// is; the first draft framed rows with control characters and returned ZERO rows because they did
|
|
68
|
+
// not survive the shell. JSON is the only framing sqlite3 offers that the payload cannot break.
|
|
69
|
+
function sqliteJson(db, sql) {
|
|
70
|
+
const r = spawnSync('sqlite3', ['-json', db, sql], { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 });
|
|
71
|
+
if (r.status !== 0) return { ok: false, rows: [], err: (r.stderr || '').trim() || `sqlite3 exit ${r.status}` };
|
|
72
|
+
const out = (r.stdout || '').trim();
|
|
73
|
+
if (!out) return { ok: true, rows: [] };
|
|
74
|
+
try { return { ok: true, rows: JSON.parse(out) }; }
|
|
75
|
+
catch (e) { return { ok: false, rows: [], err: `unparseable sqlite3 -json output: ${e.message}` }; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* TWO SCHEMA GENERATIONS EXIST IN THE WILD, and a loader that knows only one is a loader that
|
|
80
|
+
* silently returns nothing. Measured across all 54 stores on this machine, 2026-07-28:
|
|
81
|
+
*
|
|
82
|
+
* LEGACY payload in `value`; `content` exists but DEFAULTS TO '' and is empty in every row.
|
|
83
|
+
* created_at = strftime('%s','now') -> epoch SECONDS
|
|
84
|
+
* CURRENT payload in `content`.
|
|
85
|
+
* created_at = strftime('%s','now') * 1000 -> epoch MILLISECONDS
|
|
86
|
+
*
|
|
87
|
+
* The first draft read `content` and assumed millis. Result: 8 projects holding 14,000+ entries —
|
|
88
|
+
* Red Clover Inn (6,358), AI Retirement Analyzer (7,198), XrAy-I, MedReview, partsourcepurchase,
|
|
89
|
+
* stratasocial, Christina Massuh, Fin Decision Website — loaded ABSOLUTELY NOTHING, and did so
|
|
90
|
+
* quietly. Their dates rendered as 1970-01-21, which is what a seconds timestamp looks like after
|
|
91
|
+
* you divide it by 1000 a second time.
|
|
92
|
+
*
|
|
93
|
+
* Both facts are DERIVED per store, never configured: columns from PRAGMA table_info, and the epoch
|
|
94
|
+
* unit from the magnitude of the value itself. Anything below 1e12 cannot be a modern millisecond
|
|
95
|
+
* timestamp (1e12 ms = 2001-09-09), so it is seconds.
|
|
96
|
+
*/
|
|
97
|
+
export function readEntries(db, { sinceMs, grep, key } = {}) {
|
|
98
|
+
if (!fs.existsSync(db)) return { ok: false, entries: [], err: `no AgentDB at ${db}` };
|
|
99
|
+
|
|
100
|
+
const cols = sqliteJson(db, "PRAGMA table_info(memory_entries);");
|
|
101
|
+
if (!cols.ok) return { ok: false, entries: [], err: cols.err };
|
|
102
|
+
const have = new Set(cols.rows.map((c) => String(c.name)));
|
|
103
|
+
if (!have.has('key')) return { ok: false, entries: [], err: 'no memory_entries.key — not an AgentDB store' };
|
|
104
|
+
|
|
105
|
+
// Payload: prefer a non-empty `content`, fall back to `value`. COALESCE+NULLIF handles the legacy
|
|
106
|
+
// rows where `content` is present-but-'' rather than NULL.
|
|
107
|
+
const payload = have.has('content') && have.has('value') ? "COALESCE(NULLIF(content,''), value)"
|
|
108
|
+
: have.has('content') ? 'content'
|
|
109
|
+
: have.has('value') ? 'value' : "''";
|
|
110
|
+
const nsCol = have.has('namespace') ? 'namespace' : "'default'";
|
|
111
|
+
const tsCol = have.has('created_at') ? 'created_at' : (have.has('updated_at') ? 'updated_at' : '0');
|
|
112
|
+
|
|
113
|
+
let where = `TRIM(${payload}) <> ''`;
|
|
114
|
+
if (key) where += ` AND key = '${String(key).replace(/'/g, "''")}'`;
|
|
115
|
+
if (grep) {
|
|
116
|
+
const g = String(grep).replace(/'/g, "''");
|
|
117
|
+
where += ` AND (lower(${payload}) LIKE lower('%${g}%') OR lower(key) LIKE lower('%${g}%'))`;
|
|
118
|
+
}
|
|
119
|
+
// NOTE: the time window is applied in JS, AFTER normalizing the epoch unit — doing it in SQL would
|
|
120
|
+
// require knowing the unit before reading a single row, which is the bug this function exists to fix.
|
|
121
|
+
const sql = `SELECT key AS k, ${nsCol} AS ns, ${tsCol} AS ts, ${payload} AS body
|
|
122
|
+
FROM memory_entries WHERE ${where} ORDER BY ${tsCol} DESC LIMIT 4000;`;
|
|
123
|
+
const r = sqliteJson(db, sql);
|
|
124
|
+
if (!r.ok) return { ok: false, entries: [], err: r.err };
|
|
125
|
+
|
|
126
|
+
const MILLIS_FLOOR = 1e12; // 1e12 ms = 2001-09-09; anything smaller is seconds
|
|
127
|
+
let entries = r.rows.map((row) => {
|
|
128
|
+
const raw = Number(row.ts ?? 0);
|
|
129
|
+
return {
|
|
130
|
+
key: String(row.k ?? ''),
|
|
131
|
+
namespace: String(row.ns ?? ''),
|
|
132
|
+
at: raw > 0 && raw < MILLIS_FLOOR ? raw * 1000 : raw,
|
|
133
|
+
content: String(row.body ?? ''),
|
|
134
|
+
};
|
|
135
|
+
}).filter((e) => e.key && e.content);
|
|
136
|
+
|
|
137
|
+
if (sinceMs) entries = entries.filter((e) => e.at >= sinceMs);
|
|
138
|
+
return { ok: true, entries };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const stamp = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 16);
|
|
142
|
+
|
|
143
|
+
export function render(entries, { budget = DEFAULT_BUDGET, header = '' } = {}) {
|
|
144
|
+
const ranked = entries
|
|
145
|
+
.map((e) => ({ ...e, cls: classify(e.key) }))
|
|
146
|
+
.sort((a, b) => (a.cls.rank - b.cls.rank) || (b.at - a.at));
|
|
147
|
+
|
|
148
|
+
const lines = [];
|
|
149
|
+
if (header) lines.push(header);
|
|
150
|
+
let used = header.length;
|
|
151
|
+
let shown = 0;
|
|
152
|
+
const withheld = [];
|
|
153
|
+
|
|
154
|
+
for (const e of ranked) {
|
|
155
|
+
const block = `\n── [${e.cls.label}] ${e.key} (${stamp(e.at)}, ${e.content.length}B)\n${e.content}\n`;
|
|
156
|
+
if (used + block.length > budget) { withheld.push(e); continue; }
|
|
157
|
+
lines.push(block);
|
|
158
|
+
used += block.length;
|
|
159
|
+
shown++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (withheld.length) {
|
|
163
|
+
// NO SILENT CAPS. Name the count, the bytes, and exactly how to read them.
|
|
164
|
+
const bytes = withheld.reduce((n, e) => n + e.content.length, 0);
|
|
165
|
+
lines.push(`\n── ⚠ BUDGET BOUND — ${withheld.length} further entr${withheld.length === 1 ? 'y' : 'ies'} `
|
|
166
|
+
+ `(${bytes.toLocaleString()} bytes) NOT shown above. This is a cap, not an absence — do not `
|
|
167
|
+
+ `read the list above as complete. Read any of them in FULL with:\n`
|
|
168
|
+
+ withheld.slice(0, 12).map((e) => ` node scripts/agentdb-context.mjs --key "${e.key}"`).join('\n')
|
|
169
|
+
+ (withheld.length > 12 ? `\n …and ${withheld.length - 12} more (--window <hours> to widen).` : ''));
|
|
170
|
+
}
|
|
171
|
+
return { text: lines.join('\n'), shown, withheld: withheld.length };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function main() {
|
|
175
|
+
const argv = process.argv.slice(2);
|
|
176
|
+
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
177
|
+
const db = arg('--db', dbPath());
|
|
178
|
+
|
|
179
|
+
const sessionStart = argv.includes('--session-start');
|
|
180
|
+
const hours = Number(arg('--window', sessionStart ? 36 : 48));
|
|
181
|
+
const grep = arg('--grep', null);
|
|
182
|
+
const key = arg('--key', null);
|
|
183
|
+
const budget = Number(arg('--budget', DEFAULT_BUDGET));
|
|
184
|
+
|
|
185
|
+
const sinceMs = key || grep ? null : Date.now() - hours * 3600_000;
|
|
186
|
+
const r = readEntries(db, { sinceMs, grep, key });
|
|
187
|
+
|
|
188
|
+
if (!r.ok) {
|
|
189
|
+
// Fail SILENT for the hook (an absent store in another project is normal), loud for a human.
|
|
190
|
+
if (!sessionStart) console.error(`[agentdb-context] ${r.err}`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
if (!r.entries.length) {
|
|
194
|
+
if (!sessionStart) console.log('[agentdb-context] no entries matched.');
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const header = sessionStart
|
|
199
|
+
? `[AgentDB — FULL PROJECT CONTEXT, last ${hours}h. THIS IS THE RECORD. Read it before you assert `
|
|
200
|
+
+ `anything about what happened, what was measured, what was decided, or what is still open. `
|
|
201
|
+
+ `It exists because "I checked and it was there" — after reading one line of a 10,000-word `
|
|
202
|
+
+ `entry — cost this project three duplicated investigations in a single day. Do not skim it; `
|
|
203
|
+
+ `it is already the summary. Where it disagrees with your memory, IT is right.]`
|
|
204
|
+
: `[AgentDB — ${r.entries.length} entr${r.entries.length === 1 ? 'y' : 'ies'}, full text]`;
|
|
205
|
+
|
|
206
|
+
const out = render(r.entries, { budget, header });
|
|
207
|
+
console.log(out.text);
|
|
208
|
+
if (!sessionStart) console.error(`\n[agentdb-context] ${out.shown} shown, ${out.withheld} withheld by budget.`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isMain() {
|
|
213
|
+
try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
214
|
+
catch { return false; }
|
|
215
|
+
}
|
|
216
|
+
if (isMain()) process.exit(main());
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agentdb-fleet-doctor.mjs — per-project AgentDB health: AUDIT everything, FIX only what is
|
|
3
|
+
// provably safe, VERIFY with a real round-trip, and REPORT the rest loudly.
|
|
4
|
+
//
|
|
5
|
+
// Born 2026-07-13 after the Helix finding: a project's store can be mechanically healthy while
|
|
6
|
+
// its contents are useless at session start — entries fragmented across namespaces the
|
|
7
|
+
// session-start hook never reads, including OTHER PROJECTS' namespaces (cross-contamination).
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT FIXES (safe, additive only):
|
|
10
|
+
// • seeds a canonical `project-state-current-<ts>` checkpoint from git history when a project
|
|
11
|
+
// has none (the session-start hook surfaces it from then on)
|
|
12
|
+
// • runs `ruflo memory distill run` (ADR-174: incremental, non-destructive, $0)
|
|
13
|
+
// WHAT IT ONLY REPORTS (never guesses):
|
|
14
|
+
// • foreign-project namespaces (moving another project's truth is how contamination
|
|
15
|
+
// COMPOUNDS — that data belongs elsewhere, a human decides)
|
|
16
|
+
// • fragmentation: how much of the store the session-start hook can actually see
|
|
17
|
+
// VERIFY: a real store→search round-trip through the ruflo CLI in the project's own namespace.
|
|
18
|
+
// Every fix is preceded by a timestamped .bak copy of the DB. Artifacts, never exit codes.
|
|
19
|
+
|
|
20
|
+
import { spawnSync, execFileSync } from 'node:child_process';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const RUFLO = path.join(os.homedir(), '.npm-global/bin/ruflo');
|
|
26
|
+
const SYSTEM_NS = new Set(['sessions', 'feedback', 'default', 'patterns', 'adr-patterns', 'adr-edges', 'metaharness-audit']);
|
|
27
|
+
|
|
28
|
+
function sql(db, q) {
|
|
29
|
+
try { return execFileSync('sqlite3', ['-noheader', db, q], { encoding: 'utf8', timeout: 15000 }).trim(); }
|
|
30
|
+
catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ruflo(cwd, args) {
|
|
34
|
+
const r = spawnSync(RUFLO, args, { cwd, encoding: 'utf8', timeout: 120000 });
|
|
35
|
+
return { out: (r.stdout || '') + (r.stderr || ''), status: r.status };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function doctor(proj) {
|
|
39
|
+
const name = path.basename(proj);
|
|
40
|
+
const db = path.join(proj, '.swarm', 'memory.db');
|
|
41
|
+
const row = { project: name, entries: 0, visible: 0, checkpoint: false, seeded: false, episodes: null, roundtrip: false, foreign: '', notes: [] };
|
|
42
|
+
if (!fs.existsSync(db)) { row.notes.push('no db'); return row; }
|
|
43
|
+
|
|
44
|
+
row.entries = Number(sql(db, 'SELECT count(*) FROM memory_entries;') ?? 0);
|
|
45
|
+
const hasSchema = sql(db, 'SELECT count(*) FROM episodes;') !== null;
|
|
46
|
+
if (!hasSchema) row.notes.push('OLD SCHEMA — needs export/init/import migration (see migrate-agentdb-schema.sh)');
|
|
47
|
+
|
|
48
|
+
// What can the session-start hook actually see?
|
|
49
|
+
row.visible = Number(sql(db, `SELECT count(*) FROM memory_entries WHERE namespace='${name}';`) ?? 0);
|
|
50
|
+
row.checkpoint = Number(sql(db, `SELECT count(*) FROM memory_entries WHERE namespace='${name}' AND key LIKE 'project-state-current%';`) ?? 0) > 0;
|
|
51
|
+
|
|
52
|
+
// Foreign-project namespaces: REPORT, never migrate.
|
|
53
|
+
const nsRows = (sql(db, 'SELECT namespace, count(*) FROM memory_entries GROUP BY namespace;') || '')
|
|
54
|
+
.split('\n').filter(Boolean).map((l) => l.split('|'));
|
|
55
|
+
row.foreign = nsRows.filter(([ns]) => ns !== name && !SYSTEM_NS.has(ns)).map(([ns, n]) => `${ns}=${n}`).join(',');
|
|
56
|
+
|
|
57
|
+
// FIX 1 (safe): seed a canonical checkpoint from git history when none exists.
|
|
58
|
+
if (!row.checkpoint) {
|
|
59
|
+
let gitlog = '';
|
|
60
|
+
try { gitlog = execFileSync('git', ['-C', proj, 'log', '--oneline', '-8'], { encoding: 'utf8', timeout: 10000 }).trim(); } catch { /* not a repo */ }
|
|
61
|
+
const val = `SEEDED CHECKPOINT ${new Date().toISOString()} (agentdb-fleet-doctor — no canonical checkpoint existed; the session-start hook had nothing to surface). Recent git history:\n${gitlog || '(not a git repo)'}\nMaintain from now on: append a NEW project-state-current-<epochms> row after meaningful work; never overwrite.`;
|
|
62
|
+
fs.copyFileSync(db, `${db}.bak-fleet-doctor-${Date.now()}`);
|
|
63
|
+
const r = ruflo(proj, ['memory', 'store', '-k', `project-state-current-${Date.now()}`, '--value', val, '-n', name]);
|
|
64
|
+
row.seeded = r.status === 0 && /stored successfully/i.test(r.out);
|
|
65
|
+
row.checkpoint = row.seeded;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// FIX 2 (safe): distill — incremental, non-destructive, $0.
|
|
69
|
+
if (hasSchema) {
|
|
70
|
+
ruflo(proj, ['memory', 'distill', 'run']);
|
|
71
|
+
row.episodes = Number(sql(db, 'SELECT count(*) FROM episodes;') ?? 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// VERIFY: real round-trip through the real CLI in the project's own namespace.
|
|
75
|
+
const canaryKey = `fleet-doctor-canary-${Date.now()}`;
|
|
76
|
+
const canaryVal = `round-trip canary ${new Date().toISOString()}`;
|
|
77
|
+
const stored = ruflo(proj, ['memory', 'store', '-k', canaryKey, '--value', canaryVal, '-n', name]);
|
|
78
|
+
if (stored.status === 0) {
|
|
79
|
+
const found = ruflo(proj, ['memory', 'search', '-q', 'fleet-doctor round-trip canary', '-n', name, '--limit', '3']);
|
|
80
|
+
// The result table TRUNCATES keys (`fleet-doctor-cana...`) — asserting on the full key was a
|
|
81
|
+
// false-negative bug (caught on first run, 2026-07-13, SpotOn: store fine, MY check wrong —
|
|
82
|
+
// the exact lesson-agentdb-was-never-broken defect class). Match what the table can show.
|
|
83
|
+
row.roundtrip = found.out.includes('fleet-doctor-cana') || found.out.includes('round-trip canary');
|
|
84
|
+
}
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const targets = process.argv.slice(2);
|
|
89
|
+
if (!targets.length) { console.error('usage: agentdb-fleet-doctor.mjs <project-dir> [...]'); process.exit(2); }
|
|
90
|
+
|
|
91
|
+
const results = [];
|
|
92
|
+
for (const t of targets) results.push(await doctor(path.resolve(t)));
|
|
93
|
+
|
|
94
|
+
console.log('\nproject | entries | visible-at-start | checkpoint | episodes | round-trip | foreign-ns | notes');
|
|
95
|
+
console.log('-'.repeat(110));
|
|
96
|
+
for (const r of results) {
|
|
97
|
+
console.log([r.project, r.entries, r.visible, r.checkpoint ? (r.seeded ? 'SEEDED' : 'yes') : 'NO',
|
|
98
|
+
r.episodes ?? 'no-schema', r.roundtrip ? 'PASS' : 'FAIL', r.foreign || '-', r.notes.join('; ') || '-'].join(' | '));
|
|
99
|
+
}
|
|
100
|
+
const bad = results.filter((r) => !r.roundtrip || !r.checkpoint);
|
|
101
|
+
console.log(`\n${results.length - bad.length}/${results.length} projects fully verified${bad.length ? ` — NEEDS ATTENTION: ${bad.map((r) => r.project).join(', ')}` : ''}`);
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/ascii-drift.mjs — ADR-055 §8. The DETERMINISTIC half of the owner's second rule.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/ascii-drift.mjs report: stale tracked diagrams + untracked candidates
|
|
5
|
+
// node scripts/ascii-drift.mjs --json machine-readable, same evaluation
|
|
6
|
+
// node scripts/ascii-drift.mjs --quiet one line, or nothing at all — for the session-start hook
|
|
7
|
+
//
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// WHY THIS IS NOT A CONVERTER, AND WHY IT IS NOT A HOOK THAT CONVERTS.
|
|
10
|
+
//
|
|
11
|
+
// The `ascii-to-svg` skill has advertised, since 2026-01-08, that it is "Fully automatic via global
|
|
12
|
+
// PostToolUse hook ~/.claude/hooks/ascii-svg-auto-sync.sh … set-and-forget." Measured 2026-07-27:
|
|
13
|
+
// THAT FILE DOES NOT EXIST and never did; the manifest was last written 2026-06-29. It could not
|
|
14
|
+
// have existed — converting a diagram needs a model, and a PostToolUse hook has a ~5s budget, no
|
|
15
|
+
// session and no tokens.
|
|
16
|
+
//
|
|
17
|
+
// The first draft of ADR-055 then proposed conversion "at pre-push" and reproduced the identical
|
|
18
|
+
// impossibility one chokepoint over: a pre-push git hook is also a shell process with no model. Both
|
|
19
|
+
// duel reviewers caught it. So the work is SPLIT along the line that actually exists:
|
|
20
|
+
//
|
|
21
|
+
// DETERMINISTIC (this file) hashing, scoring, comparing. No model, no network, no writes.
|
|
22
|
+
// GENERATIVE (the skill) the conversion itself, invoked by a model that is actually present.
|
|
23
|
+
//
|
|
24
|
+
// and the deterministic half reports AT SESSION START — the one chokepoint where a model is in the
|
|
25
|
+
// room and can act on what it is told.
|
|
26
|
+
//
|
|
27
|
+
// THIS FILE OWNS NO FORMAT. Every rule below is the SKILL's, read from its own files, because
|
|
28
|
+
// naming our own would be the silent substitution `substitution:check` exists to prevent:
|
|
29
|
+
// · confidence scoring + negative signals + thresholds -> the skill's detection.md
|
|
30
|
+
// · override markers -> the skill's SKILL.md ("Override Markers")
|
|
31
|
+
// · manifest schema + ASCII normalization -> the skill's change-tracking.md
|
|
32
|
+
// If the skill changes its spec, this file is wrong and must follow it, not the other way round.
|
|
33
|
+
//
|
|
34
|
+
// IT NEVER BLOCKS AND NEVER WRITES. A stale picture is not a correctness defect, and a gate that
|
|
35
|
+
// refuses a push over a diagram is a gate removed within the week (DDD-0008, the cut Diagram
|
|
36
|
+
// aggregate). Worst case here is a line of text nobody acts on.
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import crypto from 'node:crypto';
|
|
41
|
+
import { spawnSync } from 'node:child_process';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
export const REPO_ROOT = path.resolve(HERE, '..');
|
|
46
|
+
export const MANIFEST = '.ascii-to-svg-manifest.json';
|
|
47
|
+
|
|
48
|
+
// Generated / vendored markdown is not authored — same exclusion list as the stamp sweep.
|
|
49
|
+
const EXCLUDED = ['node_modules', '.git', 'kb', 'dist', 'clones', 'archive', '.agentic-qe', 'coverage', 'build', 'tmp', '.swarm'];
|
|
50
|
+
|
|
51
|
+
// ── the skill's normalization (change-tracking.md, "Hash Calculation") ────────────────────────────
|
|
52
|
+
// 1. trim leading/trailing whitespace from each line · 2. drop empty lines at start/end
|
|
53
|
+
// 3. normalize line endings to \n · 4. remove the fence markers themselves
|
|
54
|
+
export function normalizeAscii(block) {
|
|
55
|
+
const lines = String(block).replace(/\r\n?/g, '\n').split('\n')
|
|
56
|
+
.filter((l) => !/^\s*(```|~~~)/.test(l))
|
|
57
|
+
.map((l) => l.trim());
|
|
58
|
+
while (lines.length && !lines[0]) lines.shift();
|
|
59
|
+
while (lines.length && !lines[lines.length - 1]) lines.pop();
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hashAscii(block) {
|
|
64
|
+
return 'sha256:' + crypto.createHash('sha256').update(normalizeAscii(block), 'utf8').digest('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── the skill's confidence scoring (detection.md, "Detection Signals") ────────────────────────────
|
|
68
|
+
const STRONG = [/[─│┌┐└┘├┤┬┴┼╔╗╚╝║═]/, /[→←↑↓↔↕▶◀▲▼►◄]/];
|
|
69
|
+
const MEDIUM = [/\+-{1,}\+/, /(-->|<--|->|<-|=>|<=|~>)/, /^\s*([-=_]{3,})\s*$/m];
|
|
70
|
+
const WEAK = [/[│┌└├].*[A-Za-z0-9]/, /^\s*[\^v]\s*$/m];
|
|
71
|
+
// Negative signals — the half a naive "has box characters" test throws away, and the reason
|
|
72
|
+
// terminal transcripts, tables and `tree` output do not become permanent false candidates.
|
|
73
|
+
const NEGATIVE = [
|
|
74
|
+
{ re: /^\s*\|.*\|\s*$[\s\S]*?^\s*\|[\s:-]+\|\s*$/m, why: 'markdown table' },
|
|
75
|
+
{ re: /\b(function|const|let|var|def|class|import|return|if|for|while)\b/, why: 'source code' },
|
|
76
|
+
{ re: /^\s*[$#>%]\s+\S/m, why: 'shell transcript' },
|
|
77
|
+
{ re: /\b\d{4}-\d{2}-\d{2}\b|\[(INFO|WARN|ERROR|DEBUG)\]/, why: 'log output' },
|
|
78
|
+
{ re: /^\s*[\w.-]+\s*[=:]\s*\S+\s*$/m, why: 'config key/value' },
|
|
79
|
+
];
|
|
80
|
+
const CONTEXT_WORDS = /(diagram|architecture|flow|structure|overview|schema|layout|design)/i;
|
|
81
|
+
|
|
82
|
+
export function scoreBlock(body, { lang = '', preceding = '' } = {}) {
|
|
83
|
+
const reasons = [];
|
|
84
|
+
let score = 0;
|
|
85
|
+
for (const re of STRONG) if (re.test(body)) { score += 25; reasons.push('+25 box/arrow glyphs'); }
|
|
86
|
+
for (const re of MEDIUM) if (re.test(body)) { score += 15; reasons.push('+15 ascii box/arrow pattern'); }
|
|
87
|
+
for (const re of WEAK) if (re.test(body)) { score += 10; reasons.push('+10 weak layout signal'); }
|
|
88
|
+
for (const n of NEGATIVE) if (n.re.test(body)) { score -= 20; reasons.push(`-20 ${n.why}`); }
|
|
89
|
+
if (CONTEXT_WORDS.test(preceding)) { score += 15; reasons.push('+15 diagram-ish context'); }
|
|
90
|
+
if (/^(diagram|ascii|art|chart|graph)$/i.test(lang)) { score += 15; reasons.push('+15 fence language hint'); }
|
|
91
|
+
if (normalizeAscii(body).split('\n').length < 3) { score -= 20; reasons.push('-20 fewer than 3 lines'); }
|
|
92
|
+
return { confidence: Math.max(0, Math.min(100, score)), reasons };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── the skill's override markers (SKILL.md, "Override Markers") ───────────────────────────────────
|
|
96
|
+
const SKIP_MARKER = /<!--\s*skip-ascii-to-svg\s*-->/;
|
|
97
|
+
const FORCE_MARKER = /<!--\s*convert-to-svg(?::\s*[\w-]+)?\s*-->/;
|
|
98
|
+
|
|
99
|
+
/** Every fenced block in a document, with the two lines of context that precede it. */
|
|
100
|
+
export function fencedBlocks(text) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
|
|
103
|
+
let open = null;
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const m = lines[i].match(/^\s*(```|~~~)(\w*)/);
|
|
106
|
+
if (!open && m) { open = { fence: m[1], lang: m[2] || '', start: i, body: [] }; continue; }
|
|
107
|
+
if (open && m && m[1] === open.fence) {
|
|
108
|
+
const preceding = lines.slice(Math.max(0, open.start - 3), open.start).join('\n');
|
|
109
|
+
out.push({ line: open.start + 1, lang: open.lang, body: open.body.join('\n'), preceding });
|
|
110
|
+
open = null; continue;
|
|
111
|
+
}
|
|
112
|
+
if (open) open.body.push(lines[i]);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function git(root, args) {
|
|
118
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
119
|
+
return { ok: r.status === 0, out: (r.stdout || '').trim() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function authoredDocs(root = REPO_ROOT) {
|
|
123
|
+
const ls = git(root, ['ls-files', '--', '*.md']);
|
|
124
|
+
if (!ls.ok) return [];
|
|
125
|
+
return ls.out.split('\n').filter(Boolean)
|
|
126
|
+
.filter((rel) => !rel.split('/').some((s) => EXCLUDED.includes(s)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function readManifest(root = REPO_ROOT) {
|
|
130
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, MANIFEST), 'utf8')); }
|
|
131
|
+
catch { return { version: '1.0.0', diagrams: [] }; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* STALE = a tracked diagram whose stored asciiHash matches NO block in its source file any more.
|
|
136
|
+
* Matched by hash across the whole file rather than by `sourceLine`, deliberately: line numbers
|
|
137
|
+
* drift on every edit above the block, and a line-anchored check would report drift for edits that
|
|
138
|
+
* never touched the diagram — the false-positive shape that gets a channel ignored.
|
|
139
|
+
*/
|
|
140
|
+
export function evaluate(root = REPO_ROOT, { threshold = 40 } = {}) {
|
|
141
|
+
const manifest = readManifest(root);
|
|
142
|
+
const tracked = Array.isArray(manifest.diagrams) ? manifest.diagrams : [];
|
|
143
|
+
const stale = [];
|
|
144
|
+
const candidates = [];
|
|
145
|
+
const byFile = new Map();
|
|
146
|
+
|
|
147
|
+
for (const rel of authoredDocs(root)) {
|
|
148
|
+
let text; try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
|
|
149
|
+
byFile.set(rel, fencedBlocks(text).map((b) => ({ ...b, hash: hashAscii(b.body) })));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// THREE OUTCOMES, NOT TWO — and getting this wrong is how the channel dies on day one.
|
|
153
|
+
// First run of this detector reported all 5 tracked README diagrams as STALE. They are not:
|
|
154
|
+
// README carries NO box-drawing characters at all, the ASCII fallbacks were removed when the
|
|
155
|
+
// diagrams were converted, and all 5 SVGs exist (2.5-4KB) and are referenced 7 times from the
|
|
156
|
+
// page. That is the HEALTHY END STATE of a conversion, reported as a defect. Five false alarms
|
|
157
|
+
// out of five tracked items, on the very first run, in a channel whose only job is to be believed.
|
|
158
|
+
// · SVG missing -> BROKEN, always worth saying
|
|
159
|
+
// · hash gone + no ASCII left in file -> CONVERTED, the fallback was removed. Silent.
|
|
160
|
+
// · hash gone + ASCII still in file -> genuinely STALE: the source is there and it differs.
|
|
161
|
+
for (const d of tracked) {
|
|
162
|
+
const blocks = byFile.get(d.sourceFile);
|
|
163
|
+
if (!blocks) { stale.push({ ...d, why: 'source file is gone or no longer scanned' }); continue; }
|
|
164
|
+
if (blocks.some((b) => b.hash === d.asciiHash)) continue; // exact match: current
|
|
165
|
+
if (!fs.existsSync(path.join(root, d.svgFile || ''))) {
|
|
166
|
+
stale.push({ ...d, why: 'the SVG file is MISSING — the page renders a broken image' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// The SVG is present. Is any ASCII still sitting in the source that could have drifted from it?
|
|
170
|
+
const leftover = blocks.some((b) => scoreBlock(b.body, { lang: b.lang, preceding: b.preceding }).confidence >= threshold);
|
|
171
|
+
if (leftover) stale.push({ ...d, why: 'ASCII source is still present in the file and no longer matches the generated SVG' });
|
|
172
|
+
// else: converted and the fallback was removed — the intended end state. Say nothing.
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const trackedHashes = new Set(tracked.map((d) => d.asciiHash));
|
|
176
|
+
for (const [rel, blocks] of byFile) {
|
|
177
|
+
for (const b of blocks) {
|
|
178
|
+
if (trackedHashes.has(b.hash)) continue; // already converted
|
|
179
|
+
if (SKIP_MARKER.test(b.preceding)) continue; // the skill's explicit opt-out
|
|
180
|
+
const forced = FORCE_MARKER.test(b.preceding);
|
|
181
|
+
const s = scoreBlock(b.body, { lang: b.lang, preceding: b.preceding });
|
|
182
|
+
const confidence = forced ? 100 : s.confidence;
|
|
183
|
+
if (confidence >= threshold) {
|
|
184
|
+
candidates.push({ file: rel, line: b.line, confidence, forced, reasons: s.reasons, lines: normalizeAscii(b.body).split('\n').length });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
candidates.sort((a, b) => b.confidence - a.confidence);
|
|
189
|
+
return { stale, candidates, trackedCount: tracked.length, scanned: byFile.size };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function main() {
|
|
193
|
+
const argv = process.argv.slice(2);
|
|
194
|
+
const r = evaluate();
|
|
195
|
+
|
|
196
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return 0; }
|
|
197
|
+
|
|
198
|
+
if (argv.includes('--quiet')) {
|
|
199
|
+
// The session-start voice: say something ONLY when there is something to do, and say it in one
|
|
200
|
+
// line. A channel that speaks every session about nothing is a channel that gets tuned out.
|
|
201
|
+
const bits = [];
|
|
202
|
+
if (r.stale.length) bits.push(`${r.stale.length} SVG diagram(s) out of date with their ASCII source`);
|
|
203
|
+
if (r.candidates.length) bits.push(`${r.candidates.length} ASCII block(s) that look like diagrams and have no SVG`);
|
|
204
|
+
if (bits.length) {
|
|
205
|
+
console.log(`[ASCII→SVG] ${bits.join(' · ')}. Say "convert the ascii diagrams" and the `
|
|
206
|
+
+ `ascii-to-svg skill will render them (it owns the conversion; this check only measures).`);
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(`\nascii→svg drift — ${r.scanned} authored document(s), ${r.trackedCount} tracked diagram(s)\n`);
|
|
212
|
+
if (r.stale.length) {
|
|
213
|
+
console.log(` ${r.stale.length} STALE — the SVG no longer matches its ASCII source:\n`);
|
|
214
|
+
for (const s of r.stale) console.log(` ▲ ${s.id} (${s.sourceFile} → ${s.svgFile})\n ${s.why}`);
|
|
215
|
+
console.log('');
|
|
216
|
+
}
|
|
217
|
+
if (r.candidates.length) {
|
|
218
|
+
console.log(` ${r.candidates.length} CANDIDATE(S) — scored by the skill's own heuristics, never auto-converted:\n`);
|
|
219
|
+
for (const c of r.candidates) {
|
|
220
|
+
console.log(` ○ ${c.file}:${c.line} confidence ${c.confidence}${c.forced ? ' (forced by marker)' : ''}, ${c.lines} lines`);
|
|
221
|
+
console.log(` ${c.reasons.join(' · ')}`);
|
|
222
|
+
}
|
|
223
|
+
console.log('\n To decline one permanently, put the skill\'s own marker above the block:');
|
|
224
|
+
console.log(' <!-- skip-ascii-to-svg -->');
|
|
225
|
+
console.log(' That is the skill\'s convention, not ours — a candidate you have judged stays judged.');
|
|
226
|
+
}
|
|
227
|
+
if (!r.stale.length && !r.candidates.length) console.log(' nothing stale, nothing unconverted worth flagging.');
|
|
228
|
+
console.log('\nThis check never converts and never blocks. Conversion belongs to the ascii-to-svg skill.\n');
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isMain() {
|
|
233
|
+
try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
234
|
+
catch { return false; }
|
|
235
|
+
}
|
|
236
|
+
if (isMain()) process.exit(main());
|