@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 +23 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-freshness.cjs +8 -4
- package/bin/gsd-t-graph-index.cjs +7 -2
- package/bin/gsd-t-graph-k1-sqlite-stream.cjs +1 -1
- package/bin/gsd-t-graph-metrics-rollup.cjs +527 -0
- package/bin/gsd-t-graph-query-cli.cjs +131 -31
- package/bin/gsd-t-graph-scip-upgrade.cjs +14 -5
- package/bin/gsd-t-graph-store-bakeoff.cjs +1 -1
- package/bin/gsd-t-graph-store-resolver.cjs +484 -0
- package/bin/gsd-t-verify-gate.cjs +9 -2
- package/bin/gsd-t.js +46 -1
- package/package.json +1 -1
- package/scripts/gsd-t-graph-intercept.js +89 -8
- package/scripts/gsd-t-read-intercept.js +87 -18
- package/templates/workflows/gsd-t-debug.workflow.js +19 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +19 -0
- package/templates/workflows/gsd-t-phase.workflow.js +19 -0
- package/templates/workflows/gsd-t-quick.workflow.js +19 -0
- package/templates/workflows/gsd-t-scan.workflow.js +78 -22
- package/templates/workflows/gsd-t-verify.workflow.js +19 -0
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.
|
|
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
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
const dbPath = explicitDbPath ||
|
|
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 =
|
|
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] :
|
|
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
|
+
};
|