@tekyzinc/gsd-t 4.13.12 → 4.15.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,28 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.15.10] - 2026-06-30
6
+
7
+ ### Added — scan builds the graph index when absent + repo-labeled `share/` export
8
+
9
+ Two scan improvements, both prompted by a real hilo-figma-atos scan that grep-fell-back and produced same-named docs across projects.
10
+
11
+ - **Scan auto-builds the graph index when absent** (`templates/workflows/gsd-t-scan.workflow.js`). The wired graph path was documented "build index if absent, then query" but the BUILD step was never wired — `GRAPH_BUILD_SCHEMA` was defined and unused. So on any project without a pre-built index (the common case), scan probed `graph status`, saw not-ok, and silently grep-fell-back — the graph was NEVER used. Observed on hilo-figma-atos (a 30M-token scan grep-fell-back purely because the index had never been built). Fix: the wired branch now runs `gsd-t graph index` (new `runCliBuild` helper), re-probes, and only falls back if the build itself fails. This is the headline value of the whole graph arc — scan now engages the graph on first run, not only when a human pre-built it.
12
+ - **Repo-labeled `share/` export + dated archive (#47)** (`templates/workflows/gsd-t-scan.workflow.js`, `commands/gsd-t-scan.md`). The LAST scan step COPIES the living docs + scan reports into `share/<file>-<repo>.md` (e.g. `share/architecture-hilo-figma-atos.md`) so files shared across projects are distinguishable. INTERNAL files keep their fixed names (`.gsd-t/techdebt.md`, `docs/architecture.md`) so all internal tooling (promote-debt, gap-analysis, complete-milestone, …) is untouched — zero blast radius. Prior scan outputs are archived to `.gsd-t/scan/archive/<name>-YYYYMMDD-HHMM.md` for diffing new-vs-prior.
13
+ - `test/m94-d6-scan-consumer.test.js`: regression tests for both (build-when-absent + re-probe; share/ export + fixed internal names + dated archive).
14
+
15
+ ## [4.14.10] - 2026-06-30
16
+
17
+ ### Added — M99: Graph Observability & Consolidation
18
+
19
+ 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.
20
+
21
+ - **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.
22
+ - **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).
23
+ - **`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.
24
+
25
+ 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).
26
+
5
27
  ## [4.13.12] - 2026-06-30
6
28
 
7
29
  ### Fixed — scan graph probe silently fell back to grep-mode despite a LIVE graph
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.13.12** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.15.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
+ };