@tekyzinc/gsd-t 4.13.11 → 4.14.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.14.10] - 2026-06-30
6
+
7
+ ### Added — M99: Graph Observability & Consolidation
8
+
9
+ All graph artifacts now live under one folder and the graph-vs-grep decision is observable, so a silent grep-fallback (the NiceNote scan #12 fence bug that started this) can never hide again.
10
+
11
+ - **Folder consolidation** — `graph.db` (+WAL/SHM), `index.scip`, `index-python.scip`, and telemetry `logs/` all consolidated under `.gsd-t/graphDB/`. A single `bin/gsd-t-graph-store-resolver.cjs` is the one source of truth for every graph path (zero raw `graph.db`/`.scip` literals survive outside it). A copy-verify-swap migration shim (`migrateGraphStore`) relocates an existing project's graph on first touch / during `update-all`: WAL-checkpoint(TRUNCATE) before copy so the `.db` is self-contained, sidecars-renamed-before-`.db`, idempotent, interruption-safe, real-project-root-only, never-orphan.
12
+ - **Telemetry ledger (Layers 1+2)** — toggleable (`GSDT_GRAPH_TELEMETRY`), fail-open, size/entry-capped + rotated append-only ledger at `.gsd-t/graphDB/logs/graph-events-NNN.jsonl`. Records: every graph query (verb/outcome/tier/latency/stale-reindex/consumer); every grep-intercept decision (structural→graph-replaced vs text→passthrough); every read-intercept decision (augment vs passthrough); and each of the 6 workflows' wiring mode (WIRED/fallback-announced/disabled).
13
+ - **`gsd-t graph metrics`** — read-only rollup: graph-hit-vs-grep-passthrough ratio, fallback-rate, p50/p95 latency, tier mix, stale/reindex frequency, per-consumer + per-verb breakdown, and the `fallbackAnnouncedDespiteHit` north-star contradiction count.
14
+
15
+ 3 domains / 2 waves (D1 migration+resolver+sink serial gate; D2 decision-logging ∥ D3 rollup). The orthogonal-triad verify caught and fixed **3 real HIGH bugs**: migration silently dropped uncheckpointed WAL rows on interruption (4000→2000 reproduced); the rollup crashed on prototype-pollution consumer/verb labels (`__proto__`/`constructor`); and the SCIP indexes were written to `.gsd-t/` instead of `graphDB/` (SC#1 falsified). Plus a casing bug where scan emitted lowercase `"wired"` vs the rollup's `"WIRED"`. All fixed with regression tests. Suite green (2600+ tests).
16
+
17
+ ## [4.13.12] - 2026-06-30
18
+
19
+ ### Fixed — scan graph probe silently fell back to grep-mode despite a LIVE graph
20
+
21
+ A `/gsd-t-scan` on NiceNote announced `GRAPH-FALLBACK — graph index not available` and ran in grep-mode, even though the dependency graph was live (156 files, compiler-accurate, all verbs + freshness verified working). Root cause, proven by reproducing the probe through a real haiku agent: the scan's `runCli` told a **haiku** agent to "return ONLY the raw JSON line" and then `JSON.parse`'d the free-text reply — haiku wrapped the JSON in a ```` ```json ```` fence, `JSON.parse` threw, the catch returned `graph-unavailable`, and the scan demoted to grep-mode. The graph, CLI, cwd, and bin-presence were all fine. This is the deterministic-orchestration anti-pattern (a reliability-critical gate routed through an LLM's free text).
22
+
23
+ - `templates/workflows/gsd-t-scan.workflow.js`: `runCli` now passes a `schema` (StructuredOutput) so the probe returns structured JSON via the tool layer, never fence-vulnerable prose; resolves project-local bin → global `gsd-t graph` fallback; drops `2>/dev/null` and surfaces `reason`/`via` in the fallback log (parse-fail vs cli-not-found vs cli-error). Mirrors the proven `gsd-t-verify.workflow.js` runCli.
24
+ - `test/m94-d6-scan-consumer.test.js`: source-structural regression test — scan `runCli` MUST use `schema:`, MUST NOT `JSON.parse(result…)`, MUST have a global-bin fallback. (The prior test only simulated recorded envelopes; it never exercised the parsing layer where the bug lived.)
25
+
26
+ The probe stays `model: "haiku"` (a mechanical CLI call) — the schema, not the model tier, is the fix. Suite: 2542/2546 pass; M71 sandbox + M85 tier lints green.
27
+
5
28
  ## [4.13.11] - 2026-06-29
6
29
 
7
30
  ### Fixed — `gsd-t graph body` reachable from the front door (M98 follow-up)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.13.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.14.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -32,6 +32,10 @@ const path = require('node:path');
32
32
  const crypto = require('node:crypto');
33
33
  const os = require('node:os');
34
34
 
35
+ // ─── M99 D1: Single store resolver ───────────────────────────────────────────
36
+ // [RULE] one-resolver-only
37
+ const _resolver = require('./gsd-t-graph-store-resolver.cjs');
38
+
35
39
  // ─── ANSI helpers ─────────────────────────────────────────────────────────────
36
40
  const C = {
37
41
  reset: '\x1b[0m',
@@ -124,10 +128,10 @@ function walkTree(dir, results = []) {
124
128
 
125
129
  function openDb(projectRoot, explicitDbPath) {
126
130
  const Database = require('./gsd-t-require-store.cjs').requireBetterSqlite();
127
- // Canonical store path is `.gsd-t/graph.db` (matches build_index's default,
128
- // the query CLI, and .gitignore). Accept an explicit path when the caller
129
- // already knows it (runFreshnessCheck passes the exact storePath).
130
- const dbPath = explicitDbPath || path.join(projectRoot, '.gsd-t', 'graph.db');
131
+ // Canonical store path is resolved via the M99 resolver (graphDB/graph.db).
132
+ // Accept an explicit path when the caller already knows it
133
+ // (runFreshnessCheck passes the exact storePath).
134
+ const dbPath = explicitDbPath || _resolver.resolveStorePath(projectRoot); // [RULE] one-resolver-only
131
135
  if (!fs.existsSync(dbPath)) return null;
132
136
  const db = new Database(dbPath);
133
137
  db.pragma('journal_mode = WAL');
@@ -46,6 +46,11 @@ const path = require('path');
46
46
  const crypto = require('crypto');
47
47
  const os = require('os');
48
48
 
49
+ // ─── M99 D1: Single store resolver ───────────────────────────────────────────
50
+ // All graph store path derivations go through the resolver.
51
+ // [RULE] one-resolver-only
52
+ const _resolver = require('./gsd-t-graph-store-resolver.cjs');
53
+
49
54
  // ── ANSI helpers ─────────────────────────────────────────────────────────────
50
55
 
51
56
  const C = {
@@ -389,7 +394,7 @@ function parse_and_put(absPath, relPath, options) {
389
394
  */
390
395
  function build_index(repoRoot, options) {
391
396
  const {
392
- dbPath = path.join(repoRoot, '.gsd-t', 'graph.db'),
397
+ dbPath = _resolver.resolveStorePath(repoRoot), // [RULE] one-resolver-only
393
398
  scip = null,
394
399
  onProgress = null,
395
400
  } = options || {};
@@ -522,7 +527,7 @@ if (require.main === module) {
522
527
  const repoIdx = args.indexOf('--repo');
523
528
  const dbIdx = args.indexOf('--db');
524
529
  const repoRoot = repoIdx !== -1 ? args[repoIdx + 1] : process.cwd();
525
- const dbPath = dbIdx !== -1 ? args[dbIdx + 1] : path.join(repoRoot, '.gsd-t', 'graph.db');
530
+ const dbPath = dbIdx !== -1 ? args[dbIdx + 1] : _resolver.resolveStorePath(repoRoot); // [RULE] one-resolver-only
526
531
 
527
532
  if (!fs.existsSync(repoRoot)) {
528
533
  errLog(`Repo root not found: ${repoRoot}`);
@@ -78,7 +78,7 @@ function run({ nodes: targetNodes, seed, tmpDir }) {
78
78
  const entityCount = Math.max(1, targetNodes - fileCount);
79
79
 
80
80
  const dir = tmpDir || fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-t-k1-stream-'));
81
- const dbPath = path.join(dir, 'graph.db');
81
+ const dbPath = path.join(dir, 'graph.db'); // spike-local-store: throwaway bench dir
82
82
  if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
83
83
 
84
84
  const tracker = peakRssTracker();
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * gsd-t-graph-metrics-rollup — M99 D3 (read-only rollup of graphDB/logs/)
6
+ *
7
+ * Reads all `graphDB/logs/graph-events-NNN.jsonl` via D1's `resolveLogsDir`.
8
+ * NEVER writes. Tolerates missing / empty / rotated ledger (zeroed report).
9
+ *
10
+ * Exports:
11
+ * rollup(projectRoot?) → RollupReport
12
+ * printRollup(projectRoot?, flags?) → prints a formatted report to stdout
13
+ *
14
+ * RollupReport shape (graph-metrics-contract.md § Rollup output shape):
15
+ * {
16
+ * totalEvents: number,
17
+ * layer1: {
18
+ * total: number,
19
+ * hitCount: number,
20
+ * hitEmptyCount: number,
21
+ * passthroughCount: number, // graph-unavailable / not-found / ambiguous / error
22
+ * hitRatio: number, // hitCount / total (0 if total=0)
23
+ * latency: { p50: number, p95: number },
24
+ * tierMix: { [tier]: number },
25
+ * staleCount: number,
26
+ * staleRate: number,
27
+ * reindexCount: number,
28
+ * reindexRate: number,
29
+ * },
30
+ * layer2a: {
31
+ * total: number,
32
+ * replacedCount: number,
33
+ * passthroughCount: number,
34
+ * },
35
+ * layer2b: {
36
+ * total: number,
37
+ * augmentCount: number,
38
+ * passthroughCount: number,
39
+ * },
40
+ * layer2c: {
41
+ * total: number,
42
+ * wiredCount: number,
43
+ * fallbackAnnouncedCount: number,
44
+ * disabledCount: number,
45
+ * fallbackRate: number, // fallbackAnnouncedCount / total (0 if total=0)
46
+ * },
47
+ * byConsumer: { [consumer]: ConsumerStats },
48
+ * byVerb: { [verb]: VerbStats },
49
+ * fallbackAnnouncedDespiteHit: number, // PRE-MORTEM #8 north-star contradiction count
50
+ * }
51
+ *
52
+ * ConsumerStats: { queryCount, hitCount, grepCount, readCount, wiringCount }
53
+ * VerbStats: { queryCount, hitCount }
54
+ *
55
+ * [RULE] read-only-rollup
56
+ * [RULE] tolerate-empty-rotated
57
+ * [RULE] import-resolveLogsDir
58
+ * [RULE] mirror-doMetrics-shape
59
+ * [RULE] fallback-despite-hit-counted
60
+ */
61
+
62
+ const fs = require("node:fs");
63
+ const path = require("node:path");
64
+
65
+ // Import D1's resolver — the ONLY place logsDir is derived. [RULE] import-resolveLogsDir
66
+ const { resolveLogsDir } = require("./gsd-t-graph-store-resolver.cjs");
67
+
68
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Enumerate all `graph-events-NNN.jsonl` files in logsDir, sorted ascending.
72
+ * Returns [] (not throws) if the dir is missing or empty. [RULE] tolerate-empty-rotated
73
+ * @param {string} logsDir
74
+ * @returns {string[]} absolute paths
75
+ */
76
+ function _listLedgerFiles(logsDir) {
77
+ try {
78
+ const entries = fs.readdirSync(logsDir);
79
+ return entries
80
+ .filter(f => /^graph-events-\d+\.jsonl$/.test(f))
81
+ .sort()
82
+ .map(f => path.join(logsDir, f));
83
+ } catch (_e) {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Parse all JSONL lines from a single file. Skips malformed lines (fail-open).
90
+ * @param {string} filePath
91
+ * @returns {object[]}
92
+ */
93
+ function _parseJSONL(filePath) {
94
+ try {
95
+ const raw = fs.readFileSync(filePath, "utf8");
96
+ const lines = raw.split("\n");
97
+ const out = [];
98
+ for (const line of lines) {
99
+ const trimmed = line.trim();
100
+ if (!trimmed) continue;
101
+ try {
102
+ out.push(JSON.parse(trimmed));
103
+ } catch {
104
+ // skip malformed line — fail-open
105
+ }
106
+ }
107
+ return out;
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Compute p50 and p95 percentiles from an array of numbers.
115
+ * @param {number[]} values
116
+ * @returns {{ p50: number, p95: number }}
117
+ */
118
+ function _percentiles(values) {
119
+ if (!values.length) return { p50: 0, p95: 0 };
120
+ const sorted = [...values].sort((a, b) => a - b);
121
+ const p50idx = Math.floor(sorted.length * 0.50);
122
+ const p95idx = Math.floor(sorted.length * 0.95);
123
+ return {
124
+ p50: sorted[Math.min(p50idx, sorted.length - 1)],
125
+ p95: sorted[Math.min(p95idx, sorted.length - 1)],
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Return a zeroed RollupReport (tolerant empty/missing ledger).
131
+ * [RULE] tolerate-empty-rotated
132
+ * @returns {object}
133
+ */
134
+ function _zeroedReport() {
135
+ return {
136
+ totalEvents: 0,
137
+ layer1: {
138
+ total: 0,
139
+ hitCount: 0,
140
+ hitEmptyCount: 0,
141
+ passthroughCount: 0,
142
+ hitRatio: 0,
143
+ latency: { p50: 0, p95: 0 },
144
+ tierMix: {},
145
+ staleCount: 0,
146
+ staleRate: 0,
147
+ reindexCount: 0,
148
+ reindexRate: 0,
149
+ },
150
+ layer2a: {
151
+ total: 0,
152
+ replacedCount: 0,
153
+ passthroughCount: 0,
154
+ },
155
+ layer2b: {
156
+ total: 0,
157
+ augmentCount: 0,
158
+ passthroughCount: 0,
159
+ },
160
+ layer2c: {
161
+ total: 0,
162
+ wiredCount: 0,
163
+ fallbackAnnouncedCount: 0,
164
+ disabledCount: 0,
165
+ fallbackRate: 0,
166
+ },
167
+ byConsumer: {},
168
+ byVerb: {},
169
+ fallbackAnnouncedDespiteHit: 0,
170
+ };
171
+ }
172
+
173
+ // ─── Main rollup ──────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Compute the rollup report from all ledger files under `projectRoot`.
177
+ * NEVER writes. Returns a zeroed report on empty / missing ledger.
178
+ *
179
+ * @param {string} [projectRoot] — optional; defaults to process.cwd()
180
+ * @returns {object} RollupReport
181
+ */
182
+ function rollup(projectRoot) {
183
+ const logsDir = resolveLogsDir(projectRoot);
184
+ const files = _listLedgerFiles(logsDir);
185
+
186
+ if (!files.length) return _zeroedReport();
187
+
188
+ // Accumulators
189
+ let totalEvents = 0;
190
+
191
+ // Layer 1 (kind:"query")
192
+ let l1Total = 0;
193
+ let l1HitCount = 0;
194
+ let l1HitEmptyCount = 0;
195
+ let l1PassthroughCount = 0;
196
+ const l1Latencies = [];
197
+ // Red Team HIGH (M99 round 4): consumer/verb/tier labels come from UNTRUSTED
198
+ // ledger input (GSDT_GRAPH_CONSUMER, hook payloads). A label of "__proto__" /
199
+ // "constructor" / "toString" defeats `if (!obj[label])` guards on a normal `{}`
200
+ // (the name inherits a truthy value from Object.prototype) → `.add()`/property
201
+ // assign hits the prototype, crashing the rollup and bricking the M99 read
202
+ // surface (SC#14). Object.create(null) gives a prototype-LESS map so no key is
203
+ // ever inherited-truthy. [RULE] rollup-prototype-safe-accumulators
204
+ const l1TierMix = Object.create(null);
205
+ let l1StaleCount = 0;
206
+ let l1ReindexCount = 0;
207
+
208
+ // Layer 2a (kind:"grep")
209
+ let l2aTotal = 0;
210
+ let l2aReplacedCount = 0;
211
+ let l2aPassthroughCount = 0;
212
+
213
+ // Layer 2b (kind:"read")
214
+ let l2bTotal = 0;
215
+ let l2bAugmentCount = 0;
216
+ let l2bPassthroughCount = 0;
217
+
218
+ // Layer 2c (kind:"wiring")
219
+ let l2cTotal = 0;
220
+ let l2cWiredCount = 0;
221
+ let l2cFallbackCount = 0;
222
+ let l2cDisabledCount = 0;
223
+
224
+ // Per-consumer, per-verb — prototype-less (Red Team HIGH, see l1TierMix above).
225
+ const byConsumer = Object.create(null);
226
+ const byVerb = Object.create(null);
227
+
228
+ // Pre-mortem #8: fallback-announced-despite-hit detection.
229
+ // A "window" here is a (consumer, day-minute bucket) pair. We collect all
230
+ // query outcome:hit events and all wiring fallback-announced events per consumer,
231
+ // then count consumers where BOTH exist. More precisely: we compute the count
232
+ // as the number of distinct consumers that have ≥1 query hit AND ≥1
233
+ // fallback-announced wiring event. This is the coarser conservative measure;
234
+ // for the strict same-window co-occurrence we use a time-bucket approach.
235
+ //
236
+ // Implementation: collect per-consumer sets of minute-buckets for hits + fallback.
237
+ const hitBuckets = Object.create(null); // consumer → Set<minute-bucket> (prototype-less; Red Team HIGH)
238
+ const fallbackBuckets = Object.create(null); // consumer → Set<minute-bucket> (prototype-less; Red Team HIGH)
239
+
240
+ function _minuteBucket(ts) {
241
+ // Truncate to the minute: "2026-06-30T14:25:37.123Z" → "2026-06-30T14:25"
242
+ if (!ts || typeof ts !== "string") return "unknown";
243
+ return ts.slice(0, 16); // "YYYY-MM-DDTHH:MM"
244
+ }
245
+
246
+ function _ensureConsumer(consumer) {
247
+ if (!byConsumer[consumer]) {
248
+ byConsumer[consumer] = {
249
+ queryCount: 0,
250
+ hitCount: 0,
251
+ grepCount: 0,
252
+ readCount: 0,
253
+ wiringCount: 0,
254
+ };
255
+ }
256
+ }
257
+
258
+ function _ensureVerb(verb) {
259
+ if (!byVerb[verb]) {
260
+ byVerb[verb] = { queryCount: 0, hitCount: 0 };
261
+ }
262
+ }
263
+
264
+ for (const filePath of files) {
265
+ const events = _parseJSONL(filePath);
266
+ totalEvents += events.length;
267
+
268
+ for (const ev of events) {
269
+ if (!ev || typeof ev !== "object" || !ev.kind) continue;
270
+ const consumer = ev.consumer || "unknown";
271
+
272
+ switch (ev.kind) {
273
+ case "query": {
274
+ l1Total++;
275
+ _ensureConsumer(consumer);
276
+ byConsumer[consumer].queryCount++;
277
+
278
+ const outcome = ev.outcome || "";
279
+ const isHit = outcome === "hit";
280
+ const isHitEmpty = outcome === "hit-empty";
281
+
282
+ if (isHit) {
283
+ l1HitCount++;
284
+ byConsumer[consumer].hitCount++;
285
+ // Record minute-bucket for co-occurrence check
286
+ if (!hitBuckets[consumer]) hitBuckets[consumer] = new Set();
287
+ hitBuckets[consumer].add(_minuteBucket(ev.ts));
288
+ } else if (isHitEmpty) {
289
+ l1HitEmptyCount++;
290
+ } else {
291
+ l1PassthroughCount++;
292
+ }
293
+
294
+ if (typeof ev.latencyMs === "number" && ev.latencyMs >= 0) {
295
+ l1Latencies.push(ev.latencyMs);
296
+ }
297
+
298
+ if (ev.tier && typeof ev.tier === "string") {
299
+ l1TierMix[ev.tier] = (l1TierMix[ev.tier] || 0) + 1;
300
+ }
301
+
302
+ if (ev.staleOnQuery === true) l1StaleCount++;
303
+ if (typeof ev.reindexedCount === "number" && ev.reindexedCount > 0) {
304
+ l1ReindexCount++;
305
+ }
306
+
307
+ const verb = ev.verb || "unknown";
308
+ _ensureVerb(verb);
309
+ byVerb[verb].queryCount++;
310
+ if (isHit) byVerb[verb].hitCount++;
311
+ break;
312
+ }
313
+
314
+ case "grep": {
315
+ l2aTotal++;
316
+ _ensureConsumer(consumer);
317
+ byConsumer[consumer].grepCount++;
318
+
319
+ if (ev.action === "replaced") l2aReplacedCount++;
320
+ else l2aPassthroughCount++;
321
+ break;
322
+ }
323
+
324
+ case "read": {
325
+ l2bTotal++;
326
+ _ensureConsumer(consumer);
327
+ byConsumer[consumer].readCount++;
328
+
329
+ if (ev.action === "augment") l2bAugmentCount++;
330
+ else l2bPassthroughCount++;
331
+ break;
332
+ }
333
+
334
+ case "wiring": {
335
+ l2cTotal++;
336
+ _ensureConsumer(consumer);
337
+ byConsumer[consumer].wiringCount++;
338
+
339
+ const mode = ev.graphWiringMode || "";
340
+ if (mode === "WIRED") {
341
+ l2cWiredCount++;
342
+ } else if (mode === "fallback-announced") {
343
+ l2cFallbackCount++;
344
+ // Record minute-bucket for co-occurrence check
345
+ if (!fallbackBuckets[consumer]) fallbackBuckets[consumer] = new Set();
346
+ fallbackBuckets[consumer].add(_minuteBucket(ev.ts));
347
+ } else if (mode === "disabled") {
348
+ l2cDisabledCount++;
349
+ }
350
+ break;
351
+ }
352
+
353
+ default:
354
+ // Unknown kind — skip silently (fail-open, future extensibility)
355
+ break;
356
+ }
357
+ }
358
+ }
359
+
360
+ // PRE-MORTEM #8: fallbackAnnouncedDespiteHit
361
+ // Count the number of minute-windows where BOTH a hit AND a fallback-announced
362
+ // appear for the SAME consumer. [RULE] fallback-despite-hit-counted
363
+ let fallbackAnnouncedDespiteHit = 0;
364
+ const allConsumers = new Set([
365
+ ...Object.keys(hitBuckets),
366
+ ...Object.keys(fallbackBuckets),
367
+ ]);
368
+ for (const c of allConsumers) {
369
+ const hits = hitBuckets[c];
370
+ const fallbacks = fallbackBuckets[c];
371
+ if (!hits || !fallbacks) continue;
372
+ // Count distinct minute-buckets shared by both sets
373
+ for (const bucket of hits) {
374
+ if (fallbacks.has(bucket)) fallbackAnnouncedDespiteHit++;
375
+ }
376
+ }
377
+
378
+ // Derived ratios
379
+ const hitRatio = l1Total > 0 ? l1HitCount / l1Total : 0;
380
+ const staleRate = l1Total > 0 ? l1StaleCount / l1Total : 0;
381
+ const reindexRate = l1Total > 0 ? l1ReindexCount / l1Total : 0;
382
+ const fallbackRate = l2cTotal > 0 ? l2cFallbackCount / l2cTotal : 0;
383
+ const latency = _percentiles(l1Latencies);
384
+
385
+ return {
386
+ totalEvents,
387
+ layer1: {
388
+ total: l1Total,
389
+ hitCount: l1HitCount,
390
+ hitEmptyCount: l1HitEmptyCount,
391
+ passthroughCount: l1PassthroughCount,
392
+ hitRatio,
393
+ latency,
394
+ tierMix: l1TierMix,
395
+ staleCount: l1StaleCount,
396
+ staleRate,
397
+ reindexCount: l1ReindexCount,
398
+ reindexRate,
399
+ },
400
+ layer2a: {
401
+ total: l2aTotal,
402
+ replacedCount: l2aReplacedCount,
403
+ passthroughCount: l2aPassthroughCount,
404
+ },
405
+ layer2b: {
406
+ total: l2bTotal,
407
+ augmentCount: l2bAugmentCount,
408
+ passthroughCount: l2bPassthroughCount,
409
+ },
410
+ layer2c: {
411
+ total: l2cTotal,
412
+ wiredCount: l2cWiredCount,
413
+ fallbackAnnouncedCount: l2cFallbackCount,
414
+ disabledCount: l2cDisabledCount,
415
+ fallbackRate,
416
+ },
417
+ byConsumer,
418
+ byVerb,
419
+ fallbackAnnouncedDespiteHit,
420
+ };
421
+ }
422
+
423
+ // ─── Formatted print (mirrors doMetrics shape) ─────────────────────────────
424
+
425
+ /**
426
+ * Print the rollup report to stdout in a human-readable format.
427
+ * Mirrors the shape/flags of `gsd-t metrics` (`doMetrics`, `bin/gsd-t.js:4697`).
428
+ * [RULE] mirror-doMetrics-shape
429
+ *
430
+ * @param {string} [projectRoot]
431
+ * @param {{ json?: boolean }} [flags]
432
+ */
433
+ function printRollup(projectRoot, flags = {}) {
434
+ const report = rollup(projectRoot);
435
+
436
+ if (flags.json) {
437
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
438
+ return;
439
+ }
440
+
441
+ const { layer1: l1, layer2a: l2a, layer2b: l2b, layer2c: l2c } = report;
442
+
443
+ const pct = (n, d) => d > 0 ? `${(n / d * 100).toFixed(1)}%` : "n/a";
444
+
445
+ const lines = [
446
+ "",
447
+ "── Graph Telemetry Rollup ──────────────────────────────────────",
448
+ ` Total events in ledger: ${report.totalEvents}`,
449
+ "",
450
+ " Layer 1 — Graph queries",
451
+ ` Total queries: ${l1.total}`,
452
+ ` Hit: ${l1.hitCount} (${pct(l1.hitCount, l1.total)})`,
453
+ ` Hit-empty: ${l1.hitEmptyCount} (${pct(l1.hitEmptyCount, l1.total)})`,
454
+ ` Passthrough/fallback: ${l1.passthroughCount} (${pct(l1.passthroughCount, l1.total)})`,
455
+ ` Latency p50/p95: ${l1.latency.p50}ms / ${l1.latency.p95}ms`,
456
+ ` Stale queries: ${l1.staleCount} (${pct(l1.staleCount, l1.total)})`,
457
+ ` Auto-reindex events: ${l1.reindexCount} (${pct(l1.reindexCount, l1.total)})`,
458
+ ];
459
+
460
+ if (Object.keys(l1.tierMix).length) {
461
+ lines.push(" Tier mix:");
462
+ for (const [tier, cnt] of Object.entries(l1.tierMix)) {
463
+ lines.push(` ${tier}: ${cnt} (${pct(cnt, l1.total)})`);
464
+ }
465
+ }
466
+
467
+ lines.push(
468
+ "",
469
+ " Layer 2a — Grep intercepts",
470
+ ` Total: ${l2a.total}`,
471
+ ` Replaced by graph: ${l2a.replacedCount} (${pct(l2a.replacedCount, l2a.total)})`,
472
+ ` Passed through: ${l2a.passthroughCount} (${pct(l2a.passthroughCount, l2a.total)})`,
473
+ "",
474
+ " Layer 2b — Read intercepts",
475
+ ` Total: ${l2b.total}`,
476
+ ` Augmented: ${l2b.augmentCount} (${pct(l2b.augmentCount, l2b.total)})`,
477
+ ` Passed through: ${l2b.passthroughCount} (${pct(l2b.passthroughCount, l2b.total)})`,
478
+ "",
479
+ " Layer 2c — Workflow wiring mode",
480
+ ` Total: ${l2c.total}`,
481
+ ` WIRED: ${l2c.wiredCount} (${pct(l2c.wiredCount, l2c.total)})`,
482
+ ` Fallback-announced: ${l2c.fallbackAnnouncedCount} (${pct(l2c.fallbackAnnouncedCount, l2c.total)})`,
483
+ ` Disabled: ${l2c.disabledCount} (${pct(l2c.disabledCount, l2c.total)})`,
484
+ ` Fallback rate: ${pct(l2c.fallbackAnnouncedCount, l2c.total)}`,
485
+ );
486
+
487
+ // North-star contradiction (pre-mortem #8)
488
+ lines.push(
489
+ "",
490
+ " ★ North-star contradiction check",
491
+ ` fallbackAnnouncedDespiteHit: ${report.fallbackAnnouncedDespiteHit}` +
492
+ (report.fallbackAnnouncedDespiteHit > 0
493
+ ? " ← consumer claimed fallback but graph DID answer ✗"
494
+ : " ← all clear ✓"),
495
+ );
496
+
497
+ if (Object.keys(report.byConsumer).length) {
498
+ lines.push("", " By consumer:");
499
+ for (const [consumer, stats] of Object.entries(report.byConsumer)) {
500
+ lines.push(
501
+ ` ${consumer}:`,
502
+ ` queries: ${stats.queryCount}, hits: ${stats.hitCount}, ` +
503
+ `grep: ${stats.grepCount}, read: ${stats.readCount}, wiring: ${stats.wiringCount}`,
504
+ );
505
+ }
506
+ }
507
+
508
+ if (Object.keys(report.byVerb).length) {
509
+ lines.push("", " By verb:");
510
+ for (const [verb, stats] of Object.entries(report.byVerb)) {
511
+ lines.push(
512
+ ` ${verb}: queries: ${stats.queryCount}, hits: ${stats.hitCount} ` +
513
+ `(${pct(stats.hitCount, stats.queryCount)})`,
514
+ );
515
+ }
516
+ }
517
+
518
+ lines.push("────────────────────────────────────────────────────────────────", "");
519
+ process.stdout.write(lines.join("\n") + "\n");
520
+ }
521
+
522
+ // ─── Exports ─────────────────────────────────────────────────────────────────
523
+
524
+ module.exports = {
525
+ rollup,
526
+ printRollup,
527
+ };