@productbrain/cli 0.1.0-beta.1440 → 0.1.0-beta.1470

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.
Files changed (64) hide show
  1. package/README.md +1 -0
  2. package/dist/__tests__/audit.test.js +16 -0
  3. package/dist/__tests__/audit.test.js.map +1 -1
  4. package/dist/__tests__/method-publish-coherency.test.d.ts +9 -0
  5. package/dist/__tests__/method-publish-coherency.test.d.ts.map +1 -0
  6. package/dist/__tests__/method-publish-coherency.test.js +71 -0
  7. package/dist/__tests__/method-publish-coherency.test.js.map +1 -0
  8. package/dist/__tests__/promote.test.js +91 -3
  9. package/dist/__tests__/promote.test.js.map +1 -1
  10. package/dist/commands/audit.d.ts.map +1 -1
  11. package/dist/commands/audit.js +9 -1
  12. package/dist/commands/audit.js.map +1 -1
  13. package/dist/commands/method.d.ts.map +1 -1
  14. package/dist/commands/method.js +10 -1
  15. package/dist/commands/method.js.map +1 -1
  16. package/dist/commands/orient.d.ts +6 -0
  17. package/dist/commands/orient.d.ts.map +1 -1
  18. package/dist/commands/orient.js.map +1 -1
  19. package/dist/commands/promote.d.ts +8 -0
  20. package/dist/commands/promote.d.ts.map +1 -1
  21. package/dist/commands/promote.js +36 -3
  22. package/dist/commands/promote.js.map +1 -1
  23. package/dist/commands/scoreboard.d.ts +28 -0
  24. package/dist/commands/scoreboard.d.ts.map +1 -0
  25. package/dist/commands/scoreboard.js +40 -0
  26. package/dist/commands/scoreboard.js.map +1 -0
  27. package/dist/commands/update.d.ts +8 -0
  28. package/dist/commands/update.d.ts.map +1 -1
  29. package/dist/commands/update.js +26 -0
  30. package/dist/commands/update.js.map +1 -1
  31. package/dist/formatters/orient.d.ts +34 -0
  32. package/dist/formatters/orient.d.ts.map +1 -1
  33. package/dist/formatters/orient.js +39 -0
  34. package/dist/formatters/orient.js.map +1 -1
  35. package/dist/formatters/promote.d.ts +5 -0
  36. package/dist/formatters/promote.d.ts.map +1 -1
  37. package/dist/formatters/promote.js +7 -0
  38. package/dist/formatters/promote.js.map +1 -1
  39. package/dist/formatters/scoreboard.d.ts +11 -0
  40. package/dist/formatters/scoreboard.d.ts.map +1 -0
  41. package/dist/formatters/scoreboard.js +48 -0
  42. package/dist/formatters/scoreboard.js.map +1 -0
  43. package/dist/index.js +28 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/lib/client.d.ts.map +1 -1
  46. package/dist/lib/client.js +2 -0
  47. package/dist/lib/client.js.map +1 -1
  48. package/dist/scoreboard/diagnose.d.ts +182 -0
  49. package/dist/scoreboard/diagnose.d.ts.map +1 -0
  50. package/dist/scoreboard/diagnose.js +250 -0
  51. package/dist/scoreboard/diagnose.js.map +1 -0
  52. package/dist/scoreboard/diagnose.test.d.ts +12 -0
  53. package/dist/scoreboard/diagnose.test.d.ts.map +1 -0
  54. package/dist/scoreboard/diagnose.test.js +192 -0
  55. package/dist/scoreboard/diagnose.test.js.map +1 -0
  56. package/dist/scoreboard/localDrift.d.ts +23 -0
  57. package/dist/scoreboard/localDrift.d.ts.map +1 -0
  58. package/dist/scoreboard/localDrift.js +111 -0
  59. package/dist/scoreboard/localDrift.js.map +1 -0
  60. package/dist/scoreboard/localDrift.test.d.ts +9 -0
  61. package/dist/scoreboard/localDrift.test.d.ts.map +1 -0
  62. package/dist/scoreboard/localDrift.test.js +82 -0
  63. package/dist/scoreboard/localDrift.test.js.map +1 -0
  64. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * SYNCED from packages/scoreboard-core/src/index.ts — DO NOT EDIT HERE.
3
+ * Canonical SSOT for the flywheel scoreboard diagnosis (consumed by BOTH the published
4
+ * `pb` CLI and the Cortex page). The CLI cannot import the workspace package at publish time
5
+ * (tsc, no bundler), so it carries this committed copy. Edit the source, then run:
6
+ * node scripts/sync-scoreboard-core.mjs
7
+ * A content-hash drift gate (scripts/check-mcp-lib-drift.mjs) fails CI if this drifts.
8
+ */
9
+ /**
10
+ * Flywheel scoreboard — the DIAGNOSIS LAYER (spec §2d), SHARED SSOT.
11
+ *
12
+ * This is the layer that turns a raw number into the agent closing the loop. It is a PURE
13
+ * mapping (no I/O — no fs, no node, no Convex runtime) from the data-layer payload
14
+ * (M1/M2/M3/materialization, from the internal `scoreboard` action) PLUS the CLI-computed M5
15
+ * local-drift, to per-metric diagnoses: `{ status, reading, cause, nextAction }`.
16
+ *
17
+ * WHY THIS LIVES IN ITS OWN WORKSPACE PACKAGE (`@product-brain/scoreboard-core`): the diagnosis
18
+ * is consumed by BOTH the `pb` CLI (`packages/cli`, pretty + --json) AND the Cortex page
19
+ * (`src/routes/(app)/w/[slug]/scoreboard`). A shared workspace package is the repo's convention
20
+ * for framework-agnostic logic two non-Convex surfaces share (mirrors `@product-brain/doc-ast`).
21
+ * The CLI cannot import across the monorepo boundary at publish time, so it consumes this body
22
+ * through a committed copy guarded by a content-hash drift check (the same pattern mcp-server uses
23
+ * for `convex/lib/flags_core.ts` → `src/flags.ts`). The SvelteKit app imports it directly.
24
+ *
25
+ * NOTHING HERE TOUCHES fs/node/Convex — the CLI-only `computeLocalDrift` (which stats
26
+ * `.productbrain/`) stays in `packages/cli/src/scoreboard/localDrift.ts`. `diagnoseM5` itself is
27
+ * pure (it maps a LocalDriftM5 → diagnosis) and lives here; only the fs *collection* of that
28
+ * LocalDriftM5 is CLI-only.
29
+ *
30
+ * HONESTY OVER FLATTERY (spec §0, §2c). The hard rules this layer encodes:
31
+ * - NO flip threshold is pinned yet (DEC-1282). Where a flip would decide ok/degraded, we
32
+ * render `awaiting` ("threshold unpinned — pin against live distribution"), NEVER an
33
+ * invented number. The RAW reading is always shown.
34
+ * - M4 is DEFERRED (TEN-2433) and ABSENT from the data payload. It is surfaced as an explicit
35
+ * `awaiting` tile ("awaiting baseline — run EXP-19"), NEVER a fake green.
36
+ * - A STANDING `unmeasured` line for coaching/moat quality (TEN-2377): four green tiles ≠ a
37
+ * healthy moat. The board must not imply moat health.
38
+ * - Every `degraded`/`awaiting` carries a concrete `nextAction`.
39
+ *
40
+ * Pure + exported so the diagnosis unit tests assert the awaiting/unmeasured/degraded mapping
41
+ * without any network or filesystem.
42
+ */
43
+ /** A metric's diagnosis status. */
44
+ export type DiagnosisStatus = 'ok' | 'degraded' | 'awaiting' | 'unmeasured';
45
+ export interface MetricDiagnosis {
46
+ /** The metric id (M1..M5) + a short human label. */
47
+ metric: string;
48
+ label: string;
49
+ status: DiagnosisStatus;
50
+ /** The raw reading, ALWAYS shown — never replaced by a verdict. */
51
+ reading: string;
52
+ /** Named cause of a degraded/awaiting/unmeasured reading (or the steady-state note). */
53
+ cause: string;
54
+ /** The concrete next action that closes the loop (empty string when status === 'ok'). */
55
+ nextAction: string;
56
+ }
57
+ export interface M1Payload {
58
+ mix: {
59
+ audited: number;
60
+ draft: number;
61
+ noDomain: number;
62
+ total: number;
63
+ };
64
+ noDomainShare: number | null;
65
+ auditedShare: number | null;
66
+ draftShare: number | null;
67
+ windowMs: number;
68
+ servedRowCount: number;
69
+ }
70
+ export interface M2Payload {
71
+ divergentBuckets: number;
72
+ fullParity: boolean;
73
+ task: string | null;
74
+ }
75
+ export interface M3Payload {
76
+ capturedCount: number;
77
+ everRatifiedCount: number;
78
+ inSessionRatifiedCount: number;
79
+ everRatifiedRate: number | null;
80
+ inSessionRatifiedRate: number | null;
81
+ sessionWindow: number;
82
+ sessionsConsidered: number;
83
+ }
84
+ export interface MaterializationPayload {
85
+ latestMaterializedAt: number | null;
86
+ materializationAgeMs: number | null;
87
+ now: number;
88
+ }
89
+ export interface ScoreboardPayload {
90
+ workspaceId: string;
91
+ generatedAt: number;
92
+ m1: M1Payload;
93
+ m2: M2Payload;
94
+ m3: M3Payload;
95
+ materialization: MaterializationPayload;
96
+ }
97
+ /**
98
+ * The CLI-computed M5 local-pull drift (A5). Produced by computeLocalDrift() (NOT the data
99
+ * layer — DEC-1284). `null` fields mean the local `.productbrain/` projection (or the server
100
+ * materialization) was unavailable; the diagnosis fails soft to `awaiting`.
101
+ */
102
+ export interface LocalDriftM5 {
103
+ /** Newest mtime across the local `.productbrain/` projection files, ms epoch. Null = absent. */
104
+ localProjectionMtime: number | null;
105
+ /** Server-side projection generation time (materialization.latestMaterializedAt). */
106
+ serverMaterializedAt: number | null;
107
+ /** How far the local pull lags the server projection, ms (server − local). Null when undeterminable. */
108
+ localLagMs: number | null;
109
+ /** Absolute age of the local projection vs now, ms. Null when the projection is absent. */
110
+ localAgeMs: number | null;
111
+ /** Set when `.productbrain/` could not be located/stat'd — diagnosis = awaiting, not a fake ok. */
112
+ unavailableReason?: string;
113
+ }
114
+ /**
115
+ * M1 — retrieval truth-state mix. The no-domain-share flip (INS-1775 floor) is UNPINNED, so the
116
+ * status is `awaiting` whenever there is data to judge (we never invent the 50% floor). With zero
117
+ * serves it is `unmeasured` (nothing served yet). The named cause for a high no-domain share is
118
+ * "domains unratified".
119
+ */
120
+ export declare function diagnoseM1(m1: M1Payload): MetricDiagnosis;
121
+ /**
122
+ * M2 — ranking agreement (tests the unify integrity). This one HAS a clean structural signal:
123
+ * post-unify, full parity (divergentBuckets === 0) is the EXPECTED steady state — that's `ok`.
124
+ * A non-zero divergence IS the regression signal — `degraded` — and the M2-magnitude flip stays
125
+ * unpinned, but the structural ok/degraded does not need a number (0 vs non-0 is exact).
126
+ */
127
+ export declare function diagnoseM2(m2: M2Payload): MetricDiagnosis;
128
+ /**
129
+ * M3 — draft-to-SSOT rate. The in-session-ratify floor (ASM-45 rolling-20 window) is UNPINNED,
130
+ * so a judged reading is `awaiting`. Zero captures in the window → `unmeasured`.
131
+ */
132
+ export declare function diagnoseM3(m3: M3Payload): MetricDiagnosis;
133
+ /**
134
+ * M4 — first-time-good (rework proxy). DEFERRED (TEN-2433) and ABSENT from the data payload.
135
+ * ALWAYS surfaced as an explicit `awaiting` tile — never a fake green, never a number. (Pure —
136
+ * takes no input; the tile is standing.)
137
+ */
138
+ export declare function diagnoseM4(): MetricDiagnosis;
139
+ /**
140
+ * M5 — projection staleness, computed as LOCAL-PULL drift at the CLI (DEC-1284). The staleness-age
141
+ * flip is UNPINNED, so a determinable reading is `awaiting`. When the local `.productbrain/`
142
+ * projection (or the server materialization) is unavailable, the status is `unmeasured` with the
143
+ * reason — fail-soft, never a fake ok.
144
+ */
145
+ export declare function diagnoseM5(m5: LocalDriftM5): MetricDiagnosis;
146
+ /**
147
+ * M5 (server view) — the Cortex page has NO local filesystem, so it cannot compute the spec's
148
+ * local-pull drift (DEC-1284). It reads only the server-side projection-generation building block
149
+ * (`materialization.latestMaterializedAt` / age) the public action already returns — adding NO new
150
+ * truth (spec §2d). This is deliberately a DIFFERENT, HONEST framing from `diagnoseM5`: it is
151
+ * SERVER-generation age, never claimed to be the local-pull staleness the CLI measures. The
152
+ * staleness-age flip is UNPINNED → `awaiting` when there is a materialization; `unmeasured` when
153
+ * the workspace has never materialized.
154
+ */
155
+ export declare function diagnoseM5FromServer(materialization: MaterializationPayload): MetricDiagnosis;
156
+ /**
157
+ * The STANDING coaching/moat-quality `unmeasured` line (TEN-2377). It is NOT a tile with a number —
158
+ * it is a permanent honesty marker so four green tiles are never read as "the moat is healthy".
159
+ */
160
+ export declare function diagnoseCoachingMoat(): MetricDiagnosis;
161
+ /** The full diagnosis bundle the CLI renders + emits in --json. */
162
+ export interface ScoreboardDiagnosis {
163
+ m1: MetricDiagnosis;
164
+ m2: MetricDiagnosis;
165
+ m3: MetricDiagnosis;
166
+ m4: MetricDiagnosis;
167
+ m5: MetricDiagnosis;
168
+ /** Standing honesty marker — coaching/moat quality is unmeasured. */
169
+ moat: MetricDiagnosis;
170
+ }
171
+ /**
172
+ * diagnose — the whole mapping: data-layer payload + CLI-computed M5 local-drift → per-metric
173
+ * diagnoses + the standing M4-awaiting + coaching-unmeasured lines. Pure.
174
+ */
175
+ export declare function diagnose(payload: ScoreboardPayload, m5: LocalDriftM5): ScoreboardDiagnosis;
176
+ /**
177
+ * diagnoseFromServer — the Cortex-page mapping: it has the public action payload but NO local fs,
178
+ * so M5 is the server-generation-age view (`diagnoseM5FromServer`) rather than the CLI local-pull
179
+ * drift. Everything else is identical to `diagnose`. Pure, browser-safe.
180
+ */
181
+ export declare function diagnoseFromServer(payload: ScoreboardPayload): ScoreboardDiagnosis;
182
+ //# sourceMappingURL=diagnose.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diagnose.d.ts","sourceRoot":"","sources":["../../src/scoreboard/diagnose.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,250 @@
1
+ /**
2
+ * SYNCED from packages/scoreboard-core/src/index.ts — DO NOT EDIT HERE.
3
+ * Canonical SSOT for the flywheel scoreboard diagnosis (consumed by BOTH the published
4
+ * `pb` CLI and the Cortex page). The CLI cannot import the workspace package at publish time
5
+ * (tsc, no bundler), so it carries this committed copy. Edit the source, then run:
6
+ * node scripts/sync-scoreboard-core.mjs
7
+ * A content-hash drift gate (scripts/check-mcp-lib-drift.mjs) fails CI if this drifts.
8
+ */
9
+ // User-facing copy carries NO raw Chain IDs (chain-id-leak guard): the CLI prints `cause` /
10
+ // `nextAction` / `reading` verbatim to any workspace's terminal, so an ID here would leak our
11
+ // internal governance refs cross-tenant. The governing entry (the no-flip-threshold decision) is
12
+ // cited in code comments only, never in these strings.
13
+ const UNPINNED = 'threshold unpinned — pin against the live distribution';
14
+ function pct(n) {
15
+ return n === null ? 'n/a' : `${(n * 100).toFixed(0)}%`;
16
+ }
17
+ function ms(n) {
18
+ if (n === null)
19
+ return 'n/a';
20
+ const h = n / (1000 * 60 * 60);
21
+ if (h < 1)
22
+ return `${(n / (1000 * 60)).toFixed(0)}m`;
23
+ if (h < 48)
24
+ return `${h.toFixed(1)}h`;
25
+ return `${(h / 24).toFixed(1)}d`;
26
+ }
27
+ /**
28
+ * M1 — retrieval truth-state mix. The no-domain-share flip (INS-1775 floor) is UNPINNED, so the
29
+ * status is `awaiting` whenever there is data to judge (we never invent the 50% floor). With zero
30
+ * serves it is `unmeasured` (nothing served yet). The named cause for a high no-domain share is
31
+ * "domains unratified".
32
+ */
33
+ export function diagnoseM1(m1) {
34
+ const reading = `served=${m1.mix.total} · audited=${pct(m1.auditedShare)} · draft=${pct(m1.draftShare)} · ` +
35
+ `no-domain=${pct(m1.noDomainShare)} (window ${ms(m1.windowMs)})`;
36
+ if (m1.mix.total === 0) {
37
+ return {
38
+ metric: 'M1',
39
+ label: 'Retrieval truth-state mix',
40
+ status: 'unmeasured',
41
+ reading,
42
+ cause: 'no governance served in the window yet — nothing to classify',
43
+ nextAction: 'drive an orient/start_pb serve, then re-read',
44
+ };
45
+ }
46
+ return {
47
+ metric: 'M1',
48
+ label: 'Retrieval truth-state mix',
49
+ status: 'awaiting',
50
+ reading,
51
+ cause: `no-domain-share floor ${UNPINNED}`,
52
+ // INS-1775 records the ≈57% no-domain baseline — cited here in a comment, not the user string.
53
+ nextAction: 'pin the no-domain floor (≈57% measured baseline); if no-domain stays high, domains are unratified → ratify the inferred-domain binding entries',
54
+ };
55
+ }
56
+ /**
57
+ * M2 — ranking agreement (tests the unify integrity). This one HAS a clean structural signal:
58
+ * post-unify, full parity (divergentBuckets === 0) is the EXPECTED steady state — that's `ok`.
59
+ * A non-zero divergence IS the regression signal — `degraded` — and the M2-magnitude flip stays
60
+ * unpinned, but the structural ok/degraded does not need a number (0 vs non-0 is exact).
61
+ */
62
+ export function diagnoseM2(m2) {
63
+ const reading = `divergentBuckets=${m2.divergentBuckets} · fullParity=${m2.fullParity}${m2.task ? ` · task="${m2.task}"` : ''}`;
64
+ if (m2.fullParity && m2.divergentBuckets === 0) {
65
+ return {
66
+ metric: 'M2',
67
+ label: 'Ranking agreement (unify integrity)',
68
+ status: 'ok',
69
+ reading,
70
+ cause: 'orient ↔ start_pb render identical set+order+tier — the unify holds',
71
+ nextAction: '',
72
+ };
73
+ }
74
+ return {
75
+ metric: 'M2',
76
+ label: 'Ranking agreement (unify integrity)',
77
+ status: 'degraded',
78
+ reading,
79
+ cause: 'the two surfaces re-forked — one is selecting/ordering governance outside the shared ranker',
80
+ // The ranking-unify tension is TEN-2431 — cited in the comment, kept out of the user string.
81
+ nextAction: 'diff getOrientEntries vs getBindingGovernanceView; restore the shared ranker as the single source',
82
+ };
83
+ }
84
+ /**
85
+ * M3 — draft-to-SSOT rate. The in-session-ratify floor (ASM-45 rolling-20 window) is UNPINNED,
86
+ * so a judged reading is `awaiting`. Zero captures in the window → `unmeasured`.
87
+ */
88
+ export function diagnoseM3(m3) {
89
+ const reading = `captured=${m3.capturedCount} · in-session=${pct(m3.inSessionRatifiedRate)} · ` +
90
+ `ever=${pct(m3.everRatifiedRate)} (over ${m3.sessionsConsidered}/${m3.sessionWindow} sessions)`;
91
+ if (m3.capturedCount === 0) {
92
+ return {
93
+ metric: 'M3',
94
+ label: 'Draft-to-SSOT rate',
95
+ status: 'unmeasured',
96
+ reading,
97
+ cause: 'no captures in the rolling session window — nothing to rate',
98
+ nextAction: 'capture in-session (pb capture), then re-read after the human accepts in Cortex',
99
+ };
100
+ }
101
+ return {
102
+ metric: 'M3',
103
+ label: 'Draft-to-SSOT rate',
104
+ status: 'awaiting',
105
+ reading,
106
+ // ASM-45 (the draft-graveyard hypothesis + rolling-20-session design) governs this metric —
107
+ // cited in comments; the user strings stay ID-free.
108
+ cause: `in-session ratify floor ${UNPINNED} (rolling-20-session design)`,
109
+ nextAction: 'pin the in-session floor (X); if rate is below it, the draft-graveyard is confirmed → accept/promote captures in Cortex',
110
+ };
111
+ }
112
+ /**
113
+ * M4 — first-time-good (rework proxy). DEFERRED (TEN-2433) and ABSENT from the data payload.
114
+ * ALWAYS surfaced as an explicit `awaiting` tile — never a fake green, never a number. (Pure —
115
+ * takes no input; the tile is standing.)
116
+ */
117
+ export function diagnoseM4() {
118
+ return {
119
+ metric: 'M4',
120
+ // The baseline-arm evidence sprint is EXP-19 (kept out of the user-facing label per the
121
+ // chain-id-leak guard; the entry id still rides nextAction, which is not a user-facing sink).
122
+ label: 'First-time-good (value)',
123
+ status: 'awaiting',
124
+ reading: 'no baseline arm yet — renders only as a PB-vs-baseline delta, never a lone number',
125
+ // The served↔rework git/CI join is deferred (TEN-2433); the baseline arm comes from the
126
+ // paired blind-judged A/B sprint (EXP-19). Both cited in comments, kept out of the user copy.
127
+ cause: 'awaiting baseline — the served↔rework join is not built; deferred from the data layer',
128
+ nextAction: 'run the paired blind-judged A/B sprint to populate the baseline arm',
129
+ };
130
+ }
131
+ /**
132
+ * M5 — projection staleness, computed as LOCAL-PULL drift at the CLI (DEC-1284). The staleness-age
133
+ * flip is UNPINNED, so a determinable reading is `awaiting`. When the local `.productbrain/`
134
+ * projection (or the server materialization) is unavailable, the status is `unmeasured` with the
135
+ * reason — fail-soft, never a fake ok.
136
+ */
137
+ export function diagnoseM5(m5) {
138
+ if (m5.unavailableReason || m5.localProjectionMtime === null) {
139
+ return {
140
+ metric: 'M5',
141
+ label: 'Projection staleness (local-pull drift)',
142
+ status: 'unmeasured',
143
+ reading: m5.unavailableReason ?? 'local .productbrain/ projection not found',
144
+ cause: 'cannot stat the local projection — local-pull drift is undeterminable here',
145
+ nextAction: 'run from a worktree with a materialized .productbrain/ (pb handshake), then re-read',
146
+ };
147
+ }
148
+ // The local projection exists, but with NO server materialization (e.g. missing/expired
149
+ // setup_receipt) there is no reference to diff against — the local-pull DRIFT (server−local,
150
+ // this metric's whole definition) is undeterminable. Report `unmeasured` (honesty over flattery)
151
+ // rather than `awaiting` with a `local-lag=n/a` that implies the drift is merely awaiting a
152
+ // threshold. local-age is still surfaced as the one signal we can honestly read.
153
+ if (m5.serverMaterializedAt === null || m5.localLagMs === null) {
154
+ return {
155
+ metric: 'M5',
156
+ label: 'Projection staleness (local-pull drift)',
157
+ status: 'unmeasured',
158
+ reading: `local-age=${ms(m5.localAgeMs)} · server materialization: none (drift undeterminable)`,
159
+ cause: 'no server materialization to diff against — local-pull drift (server−local) cannot be computed',
160
+ nextAction: 'materialize the projection server-side (pb handshake), then re-read to measure drift',
161
+ };
162
+ }
163
+ const lag = m5.localLagMs;
164
+ const age = m5.localAgeMs;
165
+ const reading = `local-lag=${ms(lag)} (server−local) · local-age=${ms(age)}`;
166
+ return {
167
+ metric: 'M5',
168
+ label: 'Projection staleness (local-pull drift)',
169
+ status: 'awaiting',
170
+ reading,
171
+ cause: `staleness-age threshold ${UNPINNED}; this is the one metric where PB can be staler than the never-drifting CLAUDE.md file`,
172
+ nextAction: 're-run pb handshake to re-pull the projection if the lag/age looks high',
173
+ };
174
+ }
175
+ /**
176
+ * M5 (server view) — the Cortex page has NO local filesystem, so it cannot compute the spec's
177
+ * local-pull drift (DEC-1284). It reads only the server-side projection-generation building block
178
+ * (`materialization.latestMaterializedAt` / age) the public action already returns — adding NO new
179
+ * truth (spec §2d). This is deliberately a DIFFERENT, HONEST framing from `diagnoseM5`: it is
180
+ * SERVER-generation age, never claimed to be the local-pull staleness the CLI measures. The
181
+ * staleness-age flip is UNPINNED → `awaiting` when there is a materialization; `unmeasured` when
182
+ * the workspace has never materialized.
183
+ */
184
+ export function diagnoseM5FromServer(materialization) {
185
+ if (materialization.latestMaterializedAt === null) {
186
+ return {
187
+ metric: 'M5',
188
+ label: 'Projection staleness (server generation age)',
189
+ status: 'unmeasured',
190
+ reading: 'no materialization receipt yet — the projection has never been generated server-side',
191
+ cause: 'nothing to age — the workspace has not materialized its projection',
192
+ nextAction: 'run pb handshake to materialize the projection, then re-read',
193
+ };
194
+ }
195
+ const age = materialization.materializationAgeMs;
196
+ return {
197
+ metric: 'M5',
198
+ label: 'Projection staleness (server generation age)',
199
+ status: 'awaiting',
200
+ reading: `server-generation age=${ms(age)} (browser view: local-pull drift is CLI-only)`,
201
+ cause: `staleness-age threshold ${UNPINNED}; this is the one metric where PB can be staler than the never-drifting CLAUDE.md file`,
202
+ nextAction: 're-run pb handshake to re-generate the projection if the age looks high',
203
+ };
204
+ }
205
+ /**
206
+ * The STANDING coaching/moat-quality `unmeasured` line (TEN-2377). It is NOT a tile with a number —
207
+ * it is a permanent honesty marker so four green tiles are never read as "the moat is healthy".
208
+ */
209
+ export function diagnoseCoachingMoat() {
210
+ return {
211
+ metric: 'MOAT',
212
+ label: 'Coaching / moat quality',
213
+ status: 'unmeasured',
214
+ // The standing-unmeasured tension is TEN-2377; the real instrument is ASM-46's
215
+ // blind-padding-rate test. Both cited in comments, kept out of the user-facing strings.
216
+ reading: 'structurally unmeasured',
217
+ cause: 'the coach skips decision/standard/business-rule (the WHY-moat types) — a counter would flatter the board',
218
+ nextAction: 'do NOT read four green tiles as a healthy moat; the real instrument is the blind-padding-rate test',
219
+ };
220
+ }
221
+ /**
222
+ * diagnose — the whole mapping: data-layer payload + CLI-computed M5 local-drift → per-metric
223
+ * diagnoses + the standing M4-awaiting + coaching-unmeasured lines. Pure.
224
+ */
225
+ export function diagnose(payload, m5) {
226
+ return {
227
+ m1: diagnoseM1(payload.m1),
228
+ m2: diagnoseM2(payload.m2),
229
+ m3: diagnoseM3(payload.m3),
230
+ m4: diagnoseM4(),
231
+ m5: diagnoseM5(m5),
232
+ moat: diagnoseCoachingMoat(),
233
+ };
234
+ }
235
+ /**
236
+ * diagnoseFromServer — the Cortex-page mapping: it has the public action payload but NO local fs,
237
+ * so M5 is the server-generation-age view (`diagnoseM5FromServer`) rather than the CLI local-pull
238
+ * drift. Everything else is identical to `diagnose`. Pure, browser-safe.
239
+ */
240
+ export function diagnoseFromServer(payload) {
241
+ return {
242
+ m1: diagnoseM1(payload.m1),
243
+ m2: diagnoseM2(payload.m2),
244
+ m3: diagnoseM3(payload.m3),
245
+ m4: diagnoseM4(),
246
+ m5: diagnoseM5FromServer(payload.materialization),
247
+ moat: diagnoseCoachingMoat(),
248
+ };
249
+ }
250
+ //# sourceMappingURL=diagnose.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diagnose.js","sourceRoot":"","sources":["../../src/scoreboard/diagnose.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Diagnosis-layer unit tests (spec §2d, A4).
3
+ *
4
+ * Asserts the pure metric→{status,reading,cause,nextAction} mapping, with explicit coverage of
5
+ * the awaiting / unmeasured / degraded / ok states and the honesty invariants:
6
+ * - No flip threshold is pinned (DEC-1282) → judged readings are `awaiting`, never an invented
7
+ * number, and the raw reading is always present.
8
+ * - M4 is a standing `awaiting` tile (deferred, TEN-2433) — never a fake green.
9
+ * - Coaching/moat quality is a standing `unmeasured` line (TEN-2377).
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=diagnose.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diagnose.test.d.ts","sourceRoot":"","sources":["../../src/scoreboard/diagnose.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Diagnosis-layer unit tests (spec §2d, A4).
3
+ *
4
+ * Asserts the pure metric→{status,reading,cause,nextAction} mapping, with explicit coverage of
5
+ * the awaiting / unmeasured / degraded / ok states and the honesty invariants:
6
+ * - No flip threshold is pinned (DEC-1282) → judged readings are `awaiting`, never an invented
7
+ * number, and the raw reading is always present.
8
+ * - M4 is a standing `awaiting` tile (deferred, TEN-2433) — never a fake green.
9
+ * - Coaching/moat quality is a standing `unmeasured` line (TEN-2377).
10
+ */
11
+ import { describe, it, expect } from 'vitest';
12
+ import { diagnose, diagnoseM1, diagnoseM2, diagnoseM3, diagnoseM4, diagnoseM5, diagnoseCoachingMoat, } from './diagnose.js';
13
+ const m1WithData = {
14
+ mix: { audited: 3, draft: 2, noDomain: 5, total: 10 },
15
+ noDomainShare: 0.5,
16
+ auditedShare: 0.3,
17
+ draftShare: 0.2,
18
+ windowMs: 30 * 24 * 60 * 60 * 1000,
19
+ servedRowCount: 10,
20
+ };
21
+ const m1Empty = {
22
+ mix: { audited: 0, draft: 0, noDomain: 0, total: 0 },
23
+ noDomainShare: null,
24
+ auditedShare: null,
25
+ draftShare: null,
26
+ windowMs: 30 * 24 * 60 * 60 * 1000,
27
+ servedRowCount: 0,
28
+ };
29
+ describe('diagnoseM1', () => {
30
+ it('is awaiting (never an invented floor) when there is data — DEC-1282', () => {
31
+ const d = diagnoseM1(m1WithData);
32
+ expect(d.status).toBe('awaiting');
33
+ expect(d.metric).toBe('M1');
34
+ // Raw reading always shown.
35
+ expect(d.reading).toContain('no-domain=50%');
36
+ expect(d.reading).toContain('served=10');
37
+ // Unpinned cause + concrete next action (domains unratified).
38
+ expect(d.cause).toMatch(/unpinned/i);
39
+ expect(d.nextAction).toMatch(/ratify/i);
40
+ });
41
+ it('is unmeasured when nothing was served', () => {
42
+ const d = diagnoseM1(m1Empty);
43
+ expect(d.status).toBe('unmeasured');
44
+ expect(d.nextAction).toMatch(/serve/i);
45
+ });
46
+ });
47
+ describe('diagnoseM2', () => {
48
+ it('is ok at full parity (post-unify steady state — 0 vs non-0 is exact, no number invented)', () => {
49
+ const m2 = { divergentBuckets: 0, fullParity: true, task: null };
50
+ const d = diagnoseM2(m2);
51
+ expect(d.status).toBe('ok');
52
+ expect(d.nextAction).toBe('');
53
+ expect(d.reading).toContain('divergentBuckets=0');
54
+ });
55
+ it('is degraded when the surfaces re-fork (regression signal)', () => {
56
+ const m2 = { divergentBuckets: 2, fullParity: false, task: 'do a thing' };
57
+ const d = diagnoseM2(m2);
58
+ expect(d.status).toBe('degraded');
59
+ expect(d.reading).toContain('divergentBuckets=2');
60
+ expect(d.reading).toContain('task="do a thing"');
61
+ expect(d.nextAction).toMatch(/shared ranker|TEN-2431/);
62
+ });
63
+ });
64
+ describe('diagnoseM3', () => {
65
+ it('is awaiting when there are captures (floor unpinned)', () => {
66
+ const m3 = {
67
+ capturedCount: 8,
68
+ everRatifiedCount: 3,
69
+ inSessionRatifiedCount: 1,
70
+ everRatifiedRate: 0.375,
71
+ inSessionRatifiedRate: 0.125,
72
+ sessionWindow: 20,
73
+ sessionsConsidered: 12,
74
+ };
75
+ const d = diagnoseM3(m3);
76
+ expect(d.status).toBe('awaiting');
77
+ expect(d.reading).toContain('captured=8');
78
+ expect(d.cause).toMatch(/unpinned/i);
79
+ });
80
+ it('is unmeasured with zero captures', () => {
81
+ const m3 = {
82
+ capturedCount: 0,
83
+ everRatifiedCount: 0,
84
+ inSessionRatifiedCount: 0,
85
+ everRatifiedRate: null,
86
+ inSessionRatifiedRate: null,
87
+ sessionWindow: 20,
88
+ sessionsConsidered: 5,
89
+ };
90
+ const d = diagnoseM3(m3);
91
+ expect(d.status).toBe('unmeasured');
92
+ });
93
+ });
94
+ describe('diagnoseM4', () => {
95
+ it('is a standing awaiting tile — never a fake green, never a number (TEN-2433)', () => {
96
+ const d = diagnoseM4();
97
+ expect(d.status).toBe('awaiting');
98
+ expect(d.metric).toBe('M4');
99
+ expect(d.cause).toMatch(/baseline/);
100
+ expect(d.nextAction).toMatch(/baseline arm|blind-judged/);
101
+ // chain-id-leak guard: the user-facing strings carry no raw Chain IDs.
102
+ expect(d.cause).not.toMatch(/\b[A-Z]{2,5}-\d+\b/);
103
+ expect(d.nextAction).not.toMatch(/\b[A-Z]{2,5}-\d+\b/);
104
+ // No invented number in the reading — it is a delta-or-nothing tile.
105
+ expect(d.reading).toMatch(/delta|baseline/i);
106
+ });
107
+ });
108
+ describe('diagnoseM5', () => {
109
+ it('is unmeasured (fail-soft) when the local projection is unavailable', () => {
110
+ const m5 = {
111
+ localProjectionMtime: null,
112
+ serverMaterializedAt: 123,
113
+ localLagMs: null,
114
+ localAgeMs: null,
115
+ unavailableReason: 'no .productbrain/ project root found',
116
+ };
117
+ const d = diagnoseM5(m5);
118
+ expect(d.status).toBe('unmeasured');
119
+ expect(d.reading).toContain('no .productbrain/');
120
+ });
121
+ it('is awaiting (threshold unpinned) when local drift is determinable', () => {
122
+ const m5 = {
123
+ localProjectionMtime: 1_000,
124
+ serverMaterializedAt: 2_000,
125
+ localLagMs: 1_000,
126
+ localAgeMs: 5_000,
127
+ };
128
+ const d = diagnoseM5(m5);
129
+ expect(d.status).toBe('awaiting');
130
+ expect(d.cause).toMatch(/unpinned/i);
131
+ // The CLAUDE.md-can't-drift framing is preserved.
132
+ expect(d.cause).toMatch(/CLAUDE\.md/);
133
+ });
134
+ it('is unmeasured (not awaiting) when the local projection exists but there is no server reference', () => {
135
+ // Local projection present, but no server materialization to diff against → the local-pull
136
+ // DRIFT (server−local) is undeterminable, so the honest status is unmeasured, not a misleading
137
+ // `awaiting` with local-lag=n/a. local-age is still surfaced.
138
+ const m5 = {
139
+ localProjectionMtime: 5_000,
140
+ serverMaterializedAt: null,
141
+ localLagMs: null,
142
+ localAgeMs: 3_000,
143
+ };
144
+ const d = diagnoseM5(m5);
145
+ expect(d.status).toBe('unmeasured');
146
+ expect(d.reading).toContain('local-age=');
147
+ expect(d.reading).toMatch(/drift undeterminable/i);
148
+ });
149
+ });
150
+ describe('diagnoseCoachingMoat', () => {
151
+ it('is a standing unmeasured line — four green tiles != healthy moat (TEN-2377)', () => {
152
+ const d = diagnoseCoachingMoat();
153
+ expect(d.status).toBe('unmeasured');
154
+ expect(d.cause).toMatch(/WHY-moat|TEN-2377|skips/i);
155
+ expect(d.nextAction).toMatch(/four green tiles/i);
156
+ });
157
+ });
158
+ describe('diagnose (bundle)', () => {
159
+ it('produces all five metric diagnoses plus the standing moat line', () => {
160
+ const payload = {
161
+ workspaceId: 'ws_1',
162
+ generatedAt: Date.now(),
163
+ m1: m1WithData,
164
+ m2: { divergentBuckets: 0, fullParity: true, task: null },
165
+ m3: {
166
+ capturedCount: 0,
167
+ everRatifiedCount: 0,
168
+ inSessionRatifiedCount: 0,
169
+ everRatifiedRate: null,
170
+ inSessionRatifiedRate: null,
171
+ sessionWindow: 20,
172
+ sessionsConsidered: 0,
173
+ },
174
+ materialization: { latestMaterializedAt: null, materializationAgeMs: null, now: Date.now() },
175
+ };
176
+ const m5 = {
177
+ localProjectionMtime: null,
178
+ serverMaterializedAt: null,
179
+ localLagMs: null,
180
+ localAgeMs: null,
181
+ unavailableReason: 'absent',
182
+ };
183
+ const bundle = diagnose(payload, m5);
184
+ expect(bundle.m1.status).toBe('awaiting');
185
+ expect(bundle.m2.status).toBe('ok');
186
+ expect(bundle.m3.status).toBe('unmeasured');
187
+ expect(bundle.m4.status).toBe('awaiting');
188
+ expect(bundle.m5.status).toBe('unmeasured');
189
+ expect(bundle.moat.status).toBe('unmeasured');
190
+ });
191
+ });
192
+ //# sourceMappingURL=diagnose.test.js.map