principles-disciple 1.52.0 → 1.54.0

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.
@@ -12,6 +12,7 @@ import {
12
12
  formatReasoningContext,
13
13
  invokeStubDreamer,
14
14
  invokeStubPhilosopher,
15
+ validateExtraction,
15
16
  type TrinityConfig,
16
17
  type DreamerOutput,
17
18
  type DreamerCandidate,
@@ -1815,3 +1816,238 @@ describe('Scribe Backward Compatibility (SCRIBE-04)', () => {
1815
1816
  expect(result.artifact!.chosenJustification).toBeUndefined();
1816
1817
  });
1817
1818
  });
1819
+
1820
+ // ---------------------------------------------------------------------------
1821
+ // Tests: validateExtraction — Hallucination Detection (SDK-QUAL-02)
1822
+ // ---------------------------------------------------------------------------
1823
+
1824
+ describe('validateExtraction — Hallucination Detection (SDK-QUAL-02)', () => {
1825
+ function makeArtifact(badDecision: string, overrides: Record<string, unknown> = {}): TrinityDraftArtifact {
1826
+ return {
1827
+ selectedCandidateIndex: 0,
1828
+ badDecision,
1829
+ betterDecision: 'Do it right instead',
1830
+ rationale: 'Because the principle says so and this is the correct approach',
1831
+ sessionId: 'session-test-123',
1832
+ principleId: 'T-01',
1833
+ sourceSnapshotRef: 'snapshot-test-001',
1834
+ telemetry: {
1835
+ chainMode: 'trinity',
1836
+ usedStubs: true,
1837
+ dreamerPassed: true,
1838
+ philosopherPassed: true,
1839
+ scribePassed: true,
1840
+ candidateCount: 1,
1841
+ selectedCandidateIndex: 0,
1842
+ stageFailures: [],
1843
+ },
1844
+ ...overrides,
1845
+ };
1846
+ }
1847
+
1848
+ function makeSnapshotWithEvidence(overrides: {
1849
+ failedToolCalls?: Array<{ toolName: string; filePath?: string; errorMessage?: string }>;
1850
+ painEvents?: Array<{ source: string; score: number; reason?: string }>;
1851
+ gateBlocks?: Array<{ toolName: string; reason: string }>;
1852
+ userCorrections?: number;
1853
+ } = {}) {
1854
+ const toolCalls = (overrides.failedToolCalls ?? []).map(tc => ({
1855
+ toolName: tc.toolName,
1856
+ outcome: 'failure' as const,
1857
+ filePath: tc.filePath ?? null,
1858
+ durationMs: null,
1859
+ exitCode: 1,
1860
+ errorType: 'runtime_error',
1861
+ errorMessage: tc.errorMessage ?? 'unknown error',
1862
+ createdAt: '2026-04-17T00:00:00.000Z',
1863
+ }));
1864
+
1865
+ const painEvents = (overrides.painEvents ?? []).map(pe => ({
1866
+ source: pe.source,
1867
+ score: pe.score,
1868
+ severity: 'medium' as const,
1869
+ reason: pe.reason ?? null,
1870
+ createdAt: '2026-04-17T00:00:00.000Z',
1871
+ }));
1872
+
1873
+ const gateBlocks = (overrides.gateBlocks ?? []).map(gb => ({
1874
+ toolName: gb.toolName,
1875
+ filePath: null,
1876
+ reason: gb.reason,
1877
+ planStatus: null,
1878
+ createdAt: '2026-04-17T00:00:00.000Z',
1879
+ }));
1880
+
1881
+ const userTurns = Array.from({ length: overrides.userCorrections ?? 0 }, (_, i) => ({
1882
+ turnIndex: i,
1883
+ correctionDetected: true,
1884
+ correctionCue: 'wrong approach',
1885
+ createdAt: '2026-04-17T00:00:00.000Z',
1886
+ }));
1887
+
1888
+ return {
1889
+ sessionId: 'session-test-123',
1890
+ startedAt: '2026-04-17T00:00:00.000Z',
1891
+ updatedAt: '2026-04-17T00:05:00.000Z',
1892
+ assistantTurns: [],
1893
+ userTurns,
1894
+ toolCalls: toolCalls,
1895
+ painEvents,
1896
+ gateBlocks,
1897
+ stats: {
1898
+ failureCount: toolCalls.length,
1899
+ totalPainEvents: painEvents.length,
1900
+ totalGateBlocks: gateBlocks.length,
1901
+ totalAssistantTurns: 5,
1902
+ totalToolCalls: 10,
1903
+ },
1904
+ };
1905
+ }
1906
+
1907
+ it('passes when badDecision references a tool failure from the snapshot', () => {
1908
+ const snapshot = makeSnapshotWithEvidence({
1909
+ failedToolCalls: [{ toolName: 'Edit', filePath: 'src/config.ts', errorMessage: 'permission denied' }],
1910
+ });
1911
+ const artifact = makeArtifact('Proceeded with Edit on src/config.ts without checking permission');
1912
+
1913
+ const result = validateExtraction(artifact, snapshot as any);
1914
+
1915
+ expect(result.isGrounded).toBe(true);
1916
+ expect(result.evidenceTypes).toContain('tool_failures');
1917
+ });
1918
+
1919
+ it('passes when badDecision references a pain event from the snapshot', () => {
1920
+ const snapshot = makeSnapshotWithEvidence({
1921
+ painEvents: [{ source: 'gate', score: 70, reason: 'accumulated friction from repeated file operation failures' }],
1922
+ });
1923
+ const artifact = makeArtifact('Ignored accumulated friction from file operations');
1924
+
1925
+ const result = validateExtraction(artifact, snapshot as any);
1926
+
1927
+ expect(result.isGrounded).toBe(true);
1928
+ expect(result.evidenceTypes).toContain('pain_events');
1929
+ });
1930
+
1931
+ it('passes when badDecision references a gate block from the snapshot', () => {
1932
+ const snapshot = makeSnapshotWithEvidence({
1933
+ gateBlocks: [{ toolName: 'Bash', reason: 'destructive command blocked by safety gate' }],
1934
+ });
1935
+ const artifact = makeArtifact('Attempted to execute a destructive Bash command that was blocked by the gate');
1936
+
1937
+ const result = validateExtraction(artifact, snapshot as any);
1938
+
1939
+ expect(result.isGrounded).toBe(true);
1940
+ expect(result.evidenceTypes).toContain('gate_blocks');
1941
+ });
1942
+
1943
+ it('passes when badDecision references user corrections', () => {
1944
+ const snapshot = makeSnapshotWithEvidence({
1945
+ userCorrections: 2,
1946
+ });
1947
+ const artifact = makeArtifact('Continued with the wrong approach despite user corrections');
1948
+
1949
+ const result = validateExtraction(artifact, snapshot as any);
1950
+
1951
+ expect(result.isGrounded).toBe(true);
1952
+ expect(result.evidenceTypes).toContain('user_corrections');
1953
+ });
1954
+
1955
+ it('detects hallucination when badDecision has no overlap with snapshot evidence', () => {
1956
+ const snapshot = makeSnapshotWithEvidence({
1957
+ failedToolCalls: [{ toolName: 'Read', filePath: 'package.json', errorMessage: 'file not found' }],
1958
+ });
1959
+ const artifact = makeArtifact('Deployed production database without running migration scripts first');
1960
+
1961
+ const result = validateExtraction(artifact, snapshot as any);
1962
+
1963
+ expect(result.isGrounded).toBe(false);
1964
+ expect(result.reason).toContain('Hallucinated extraction');
1965
+ });
1966
+
1967
+ it('passes when snapshot has no evidence at all (no signal to validate against)', () => {
1968
+ const snapshot = makeSnapshotWithEvidence();
1969
+ const artifact = makeArtifact('Made an incorrect decision during the session');
1970
+
1971
+ const result = validateExtraction(artifact, snapshot as any);
1972
+
1973
+ // No evidence means we cannot validate -- allow through
1974
+ expect(result.isGrounded).toBe(true);
1975
+ expect(result.evidenceTypes).toHaveLength(0);
1976
+ });
1977
+
1978
+ it('provides evidence preview for telemetry', () => {
1979
+ const snapshot = makeSnapshotWithEvidence({
1980
+ failedToolCalls: [{ toolName: 'Write', filePath: 'output.log', errorMessage: 'permission denied for write operation' }],
1981
+ painEvents: [{ source: 'hook', score: 80, reason: 'repeated permission denied failures during write operation' }],
1982
+ });
1983
+ const artifact = makeArtifact('Proceeded with write operation on output.log despite permission denied error');
1984
+
1985
+ const result = validateExtraction(artifact, snapshot as any);
1986
+
1987
+ expect(result.isGrounded).toBe(true);
1988
+ expect(result.evidencePreview.length).toBeGreaterThan(0);
1989
+ expect(result.evidenceTypes).toContain('tool_failures');
1990
+ expect(result.evidenceTypes).toContain('pain_events');
1991
+ });
1992
+
1993
+ it('detects hallucination with unrelated but specific badDecision text', () => {
1994
+ const snapshot = makeSnapshotWithEvidence({
1995
+ painEvents: [{ source: 'gate', score: 60, reason: 'rate limit exceeded for API calls' }],
1996
+ });
1997
+ const artifact = makeArtifact('Deleted the primary database without creating a backup first');
1998
+
1999
+ const result = validateExtraction(artifact, snapshot as any);
2000
+
2001
+ expect(result.isGrounded).toBe(false);
2002
+ });
2003
+
2004
+ it('runTrinity stub path fails when hallucination is detected', () => {
2005
+ // Create a snapshot with failure signals so stub candidates are generated
2006
+ // but override the tool calls to be something completely unrelated to what
2007
+ // the stub Dreamer generates (which mentions "failing operation")
2008
+ const snapshot = {
2009
+ sessionId: 'session-hallucination-test',
2010
+ startedAt: '2026-04-17T00:00:00.000Z',
2011
+ updatedAt: '2026-04-17T00:05:00.000Z',
2012
+ assistantTurns: [],
2013
+ userTurns: [],
2014
+ toolCalls: [
2015
+ {
2016
+ toolName: 'Grep',
2017
+ outcome: 'failure' as const,
2018
+ filePath: null,
2019
+ durationMs: null,
2020
+ exitCode: 1,
2021
+ errorType: 'timeout',
2022
+ errorMessage: 'search timed out after 30 seconds',
2023
+ createdAt: '2026-04-17T00:00:00.000Z',
2024
+ },
2025
+ ],
2026
+ painEvents: [],
2027
+ gateBlocks: [],
2028
+ stats: {
2029
+ failureCount: 1,
2030
+ totalPainEvents: 0,
2031
+ totalGateBlocks: 0,
2032
+ totalAssistantTurns: 2,
2033
+ totalToolCalls: 1,
2034
+ },
2035
+ };
2036
+
2037
+ const config: TrinityConfig = {
2038
+ useTrinity: true,
2039
+ maxCandidates: 3,
2040
+ useStubs: true,
2041
+ };
2042
+
2043
+ const result = runTrinity({ snapshot: snapshot as any, principleId: 'T-08', config });
2044
+
2045
+ // The stub Dreamer generates candidates mentioning "failing operation" and "config.json"
2046
+ // The snapshot has a Grep failure with "search timed out"
2047
+ // With the normalized token matching: badDecisionTokens = {retry,faili,oper,diagnos,root,caus}
2048
+ // and evidenceTokens = {search,timed,after,seconds,timedout} — no overlap → extraction fails
2049
+ // So result.success must be false with a Hallucinated failure.
2050
+ expect(result.success).toBe(false);
2051
+ expect(result.failures.some(f => f.reason?.includes('Hallucinated'))).toBe(true);
2052
+ });
2053
+ });
@@ -0,0 +1,383 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { calculateBaselines } from '../../src/core/observability.js';
6
+ import type { ObservabilityBaselines } from '../../src/core/observability.js';
7
+ import { loadLedger, saveLedger } from '../../src/core/principle-tree-ledger.js';
8
+ import type { HybridLedgerStore } from '../../src/core/principle-tree-ledger.js';
9
+ import { safeRmDir } from '../test-utils.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function createTmpDir(): string {
16
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-observability-test-'));
17
+ }
18
+
19
+ function createEmptyStore(): HybridLedgerStore {
20
+ return {
21
+ trainingStore: {},
22
+ tree: {
23
+ principles: {},
24
+ rules: {},
25
+ implementations: {},
26
+ metrics: {},
27
+ lastUpdated: new Date(0).toISOString(),
28
+ },
29
+ };
30
+ }
31
+
32
+ function createTestPrinciple(id: string, status: string, priority: string) {
33
+ return {
34
+ id,
35
+ version: 1,
36
+ text: `Test principle ${id}`,
37
+ triggerPattern: 'test',
38
+ action: 'verify',
39
+ status,
40
+ priority,
41
+ scope: 'general',
42
+ evaluability: 'manual_only' as const,
43
+ valueScore: 0,
44
+ adherenceRate: 0,
45
+ painPreventedCount: 0,
46
+ derivedFromPainIds: [] as string[],
47
+ ruleIds: [] as string[],
48
+ conflictsWithPrincipleIds: [] as string[],
49
+ createdAt: '2026-04-17T00:00:00.000Z',
50
+ updatedAt: '2026-04-17T00:00:00.000Z',
51
+ };
52
+ }
53
+
54
+ function seedStore(stateDir: string, store: HybridLedgerStore): void {
55
+ saveLedger(stateDir, store);
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Tests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe('calculateBaselines', () => {
63
+ let tmpDir: string;
64
+
65
+ beforeEach(() => {
66
+ tmpDir = createTmpDir();
67
+ seedStore(tmpDir, createEmptyStore());
68
+ });
69
+
70
+ afterEach(() => {
71
+ safeRmDir(tmpDir);
72
+ });
73
+
74
+ // -------------------------------------------------------------------------
75
+ // Empty state
76
+ // -------------------------------------------------------------------------
77
+
78
+ it('returns zeros for empty store', () => {
79
+ const baselines = calculateBaselines(tmpDir);
80
+
81
+ expect(baselines.principleStock).toBe(0);
82
+ expect(baselines.totalRules).toBe(0);
83
+ expect(baselines.totalImplementations).toBe(0);
84
+ expect(baselines.avgRulesPerPrinciple).toBe(0);
85
+ expect(baselines.avgImplementationsPerRule).toBe(0);
86
+ expect(baselines.totalPainEvents).toBe(0);
87
+ expect(baselines.associationRate).toBe(0);
88
+ expect(baselines.internalizedCount).toBe(0);
89
+ expect(baselines.internalizationRate).toBe(0);
90
+ });
91
+
92
+ it('sets calculatedAt to current time', () => {
93
+ const before = new Date().toISOString();
94
+ const baselines = calculateBaselines(tmpDir);
95
+ const after = new Date().toISOString();
96
+
97
+ expect(baselines.calculatedAt >= before).toBe(true);
98
+ expect(baselines.calculatedAt <= after).toBe(true);
99
+ });
100
+
101
+ // -------------------------------------------------------------------------
102
+ // Principle Stock
103
+ // -------------------------------------------------------------------------
104
+
105
+ it('counts all principles in the ledger', () => {
106
+ const store = createEmptyStore();
107
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
108
+ store.tree.principles['P-002'] = createTestPrinciple('P-002', 'candidate', 'P2');
109
+ store.tree.principles['P-003'] = createTestPrinciple('P-003', 'deprecated', 'P1');
110
+ seedStore(tmpDir, store);
111
+
112
+ const baselines = calculateBaselines(tmpDir);
113
+ expect(baselines.principleStock).toBe(3);
114
+ });
115
+
116
+ // -------------------------------------------------------------------------
117
+ // Structure
118
+ // -------------------------------------------------------------------------
119
+
120
+ it('calculates avgRulesPerPrinciple', () => {
121
+ const store = createEmptyStore();
122
+ store.tree.principles['P-001'] = {
123
+ ...createTestPrinciple('P-001', 'active', 'P1'),
124
+ ruleIds: ['R-001', 'R-002'],
125
+ };
126
+ store.tree.principles['P-002'] = {
127
+ ...createTestPrinciple('P-002', 'active', 'P1'),
128
+ ruleIds: ['R-003'],
129
+ };
130
+ store.tree.rules['R-001'] = { id: 'R-001', principleId: 'P-001', implementationIds: [] } as any;
131
+ store.tree.rules['R-002'] = { id: 'R-002', principleId: 'P-001', implementationIds: [] } as any;
132
+ store.tree.rules['R-003'] = { id: 'R-003', principleId: 'P-002', implementationIds: [] } as any;
133
+ seedStore(tmpDir, store);
134
+
135
+ const baselines = calculateBaselines(tmpDir);
136
+ expect(baselines.totalRules).toBe(3);
137
+ expect(baselines.avgRulesPerPrinciple).toBe(1.5); // 3 rules / 2 principles
138
+ });
139
+
140
+ it('calculates avgImplementationsPerRule', () => {
141
+ const store = createEmptyStore();
142
+ store.tree.principles['P-001'] = {
143
+ ...createTestPrinciple('P-001', 'active', 'P1'),
144
+ ruleIds: ['R-001'],
145
+ };
146
+ store.tree.rules['R-001'] = {
147
+ id: 'R-001',
148
+ principleId: 'P-001',
149
+ implementationIds: ['I-001', 'I-002'],
150
+ } as any;
151
+ store.tree.implementations['I-001'] = { id: 'I-001', ruleId: 'R-001' } as any;
152
+ store.tree.implementations['I-002'] = { id: 'I-002', ruleId: 'R-001' } as any;
153
+ seedStore(tmpDir, store);
154
+
155
+ const baselines = calculateBaselines(tmpDir);
156
+ expect(baselines.totalImplementations).toBe(2);
157
+ expect(baselines.avgImplementationsPerRule).toBe(2); // 2 impls / 1 rule
158
+ });
159
+
160
+ it('returns 0 for structure metrics when no principles exist', () => {
161
+ seedStore(tmpDir, createEmptyStore());
162
+ const baselines = calculateBaselines(tmpDir);
163
+
164
+ expect(baselines.avgRulesPerPrinciple).toBe(0);
165
+ expect(baselines.avgImplementationsPerRule).toBe(0);
166
+ });
167
+
168
+ // -------------------------------------------------------------------------
169
+ // Association Rate
170
+ // -------------------------------------------------------------------------
171
+
172
+ it('returns 0 association rate when no pain events exist', () => {
173
+ const store = createEmptyStore();
174
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
175
+ seedStore(tmpDir, store);
176
+
177
+ const baselines = calculateBaselines(tmpDir);
178
+ expect(baselines.totalPainEvents).toBe(0);
179
+ expect(baselines.associationRate).toBe(0);
180
+ });
181
+
182
+ it('computes association rate as principles / pain events', () => {
183
+ const store = createEmptyStore();
184
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
185
+ store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P2');
186
+ seedStore(tmpDir, store);
187
+
188
+ // Create a trajectory DB with pain events
189
+ const dbPath = path.join(tmpDir, 'trajectory.db');
190
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
191
+ const Database = require('better-sqlite3');
192
+ const db = new Database(dbPath);
193
+ db.exec(`
194
+ CREATE TABLE pain_events (id TEXT PRIMARY KEY, created_at TEXT);
195
+ INSERT INTO pain_events VALUES ('pe-1', '2026-04-17T00:00:00Z');
196
+ INSERT INTO pain_events VALUES ('pe-2', '2026-04-17T00:01:00Z');
197
+ INSERT INTO pain_events VALUES ('pe-3', '2026-04-17T00:02:00Z');
198
+ INSERT INTO pain_events VALUES ('pe-4', '2026-04-17T00:03:00Z');
199
+ `);
200
+ db.close();
201
+
202
+ const baselines = calculateBaselines(tmpDir);
203
+ expect(baselines.totalPainEvents).toBe(4);
204
+ expect(baselines.associationRate).toBe(0.5); // 2 principles / 4 pain events
205
+ });
206
+
207
+ // -------------------------------------------------------------------------
208
+ // Internalization Rate
209
+ // -------------------------------------------------------------------------
210
+
211
+ it('computes internalization rate from training store', () => {
212
+ const store = createEmptyStore();
213
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
214
+ store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P2');
215
+ store.tree.principles['P-003'] = createTestPrinciple('P-003', 'candidate', 'P2');
216
+ store.trainingStore['P-001'] = {
217
+ principleId: 'P-001',
218
+ evaluability: 'deterministic',
219
+ applicableOpportunityCount: 10,
220
+ observedViolationCount: 0,
221
+ complianceRate: 1.0,
222
+ violationTrend: 0,
223
+ generatedSampleCount: 5,
224
+ approvedSampleCount: 5,
225
+ includedTrainRunIds: ['run-1'],
226
+ deployedCheckpointIds: ['ckpt-1'],
227
+ internalizationStatus: 'internalized',
228
+ };
229
+ store.trainingStore['P-002'] = {
230
+ principleId: 'P-002',
231
+ evaluability: 'weak_heuristic',
232
+ applicableOpportunityCount: 3,
233
+ observedViolationCount: 1,
234
+ complianceRate: 0.67,
235
+ violationTrend: 0,
236
+ generatedSampleCount: 0,
237
+ approvedSampleCount: 0,
238
+ includedTrainRunIds: [],
239
+ deployedCheckpointIds: [],
240
+ internalizationStatus: 'in_training',
241
+ };
242
+ seedStore(tmpDir, store);
243
+
244
+ const baselines = calculateBaselines(tmpDir);
245
+ expect(baselines.internalizedCount).toBe(1);
246
+ // 1 internalized / 3 total principles = 0.333
247
+ expect(baselines.internalizationRate).toBeGreaterThan(0.33);
248
+ expect(baselines.internalizationRate).toBeLessThanOrEqual(0.334);
249
+ });
250
+
251
+ it('returns 0 internalization rate when no principles are internalized', () => {
252
+ const store = createEmptyStore();
253
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
254
+ store.trainingStore['P-001'] = {
255
+ principleId: 'P-001',
256
+ evaluability: 'manual_only',
257
+ applicableOpportunityCount: 0,
258
+ observedViolationCount: 0,
259
+ complianceRate: 0,
260
+ violationTrend: 0,
261
+ generatedSampleCount: 0,
262
+ approvedSampleCount: 0,
263
+ includedTrainRunIds: [],
264
+ deployedCheckpointIds: [],
265
+ internalizationStatus: 'prompt_only',
266
+ };
267
+ seedStore(tmpDir, store);
268
+
269
+ const baselines = calculateBaselines(tmpDir);
270
+ expect(baselines.internalizedCount).toBe(0);
271
+ expect(baselines.internalizationRate).toBe(0);
272
+ });
273
+
274
+ // -------------------------------------------------------------------------
275
+ // Distributions
276
+ // -------------------------------------------------------------------------
277
+
278
+ it('computes status distribution', () => {
279
+ const store = createEmptyStore();
280
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
281
+ store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P2');
282
+ store.tree.principles['P-003'] = createTestPrinciple('P-003', 'candidate', 'P1');
283
+ store.tree.principles['P-004'] = createTestPrinciple('P-004', 'deprecated', 'P2');
284
+ seedStore(tmpDir, store);
285
+
286
+ const baselines = calculateBaselines(tmpDir);
287
+ expect(baselines.statusDistribution.active).toBe(2);
288
+ expect(baselines.statusDistribution.candidate).toBe(1);
289
+ expect(baselines.statusDistribution.deprecated).toBe(1);
290
+ });
291
+
292
+ it('computes priority distribution', () => {
293
+ const store = createEmptyStore();
294
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P0');
295
+ store.tree.principles['P-002'] = createTestPrinciple('P-002', 'active', 'P1');
296
+ store.tree.principles['P-003'] = createTestPrinciple('P-003', 'active', 'P1');
297
+ store.tree.principles['P-004'] = createTestPrinciple('P-004', 'active', 'P2');
298
+ seedStore(tmpDir, store);
299
+
300
+ const baselines = calculateBaselines(tmpDir);
301
+ expect(baselines.priorityDistribution.P0).toBe(1);
302
+ expect(baselines.priorityDistribution.P1).toBe(2);
303
+ expect(baselines.priorityDistribution.P2).toBe(1);
304
+ });
305
+
306
+ it('computes internalization distribution', () => {
307
+ const store = createEmptyStore();
308
+ store.trainingStore['p-1'] = {
309
+ principleId: 'p-1',
310
+ evaluability: 'deterministic',
311
+ applicableOpportunityCount: 5,
312
+ observedViolationCount: 0,
313
+ complianceRate: 1.0,
314
+ violationTrend: 0,
315
+ generatedSampleCount: 3,
316
+ approvedSampleCount: 3,
317
+ includedTrainRunIds: [],
318
+ deployedCheckpointIds: [],
319
+ internalizationStatus: 'internalized',
320
+ };
321
+ store.trainingStore['p-2'] = {
322
+ principleId: 'p-2',
323
+ evaluability: 'manual_only',
324
+ applicableOpportunityCount: 0,
325
+ observedViolationCount: 0,
326
+ complianceRate: 0,
327
+ violationTrend: 0,
328
+ generatedSampleCount: 0,
329
+ approvedSampleCount: 0,
330
+ includedTrainRunIds: [],
331
+ deployedCheckpointIds: [],
332
+ internalizationStatus: 'prompt_only',
333
+ };
334
+ seedStore(tmpDir, store);
335
+
336
+ const baselines = calculateBaselines(tmpDir);
337
+ expect(baselines.internalizationDistribution.internalized).toBe(1);
338
+ expect(baselines.internalizationDistribution.prompt_only).toBe(1);
339
+ });
340
+
341
+ // -------------------------------------------------------------------------
342
+ // Persistence
343
+ // -------------------------------------------------------------------------
344
+
345
+ it('persists baselines to .state/baselines.json', () => {
346
+ calculateBaselines(tmpDir);
347
+
348
+ const baselinesPath = path.join(tmpDir, 'baselines.json');
349
+ expect(fs.existsSync(baselinesPath)).toBe(true);
350
+
351
+ const raw = JSON.parse(fs.readFileSync(baselinesPath, 'utf8')) as ObservabilityBaselines;
352
+ expect(raw.principleStock).toBe(0);
353
+ expect(raw.calculatedAt).toBeDefined();
354
+ });
355
+
356
+ it('overwrites previous baselines on recalculation', () => {
357
+ // First calculation with empty store
358
+ calculateBaselines(tmpDir);
359
+
360
+ // Add a principle
361
+ const store = createEmptyStore();
362
+ store.tree.principles['P-001'] = createTestPrinciple('P-001', 'active', 'P1');
363
+ seedStore(tmpDir, store);
364
+
365
+ // Second calculation
366
+ calculateBaselines(tmpDir);
367
+
368
+ const baselinesPath = path.join(tmpDir, 'baselines.json');
369
+ const raw = JSON.parse(fs.readFileSync(baselinesPath, 'utf8')) as ObservabilityBaselines;
370
+ expect(raw.principleStock).toBe(1);
371
+ });
372
+
373
+ // -------------------------------------------------------------------------
374
+ // Edge cases
375
+ // -------------------------------------------------------------------------
376
+
377
+ it('handles missing state directory gracefully', () => {
378
+ const missingDir = path.join(tmpDir, 'nonexistent');
379
+ // loadLedger handles missing dirs by returning empty store
380
+ const baselines = calculateBaselines(missingDir);
381
+ expect(baselines.principleStock).toBe(0);
382
+ });
383
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { clearPainFlag, PAIN_FLAG_FILENAME } from '../../src/core/pain-lifecycle.js';
5
+ import { resolvePdPath } from '../../src/core/paths.js';
6
+
7
+ describe('PainLifecycle', () => {
8
+ const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-test-'));
9
+ const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
10
+
11
+ beforeEach(() => {
12
+ const stateDir = path.dirname(painFlagPath);
13
+ if (!fs.existsSync(stateDir)) {
14
+ fs.mkdirSync(stateDir, { recursive: true });
15
+ }
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (fs.existsSync(painFlagPath)) fs.unlinkSync(painFlagPath);
20
+ });
21
+
22
+ it('should delete .pain_flag file when it exists', () => {
23
+ fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: test\ntime: 2026-01-01\n', 'utf8');
24
+ expect(fs.existsSync(painFlagPath)).toBe(true);
25
+ clearPainFlag(workspaceDir);
26
+ expect(fs.existsSync(painFlagPath)).toBe(false);
27
+ });
28
+
29
+ it('should not throw when .pain_flag does not exist', () => {
30
+ expect(fs.existsSync(painFlagPath)).toBe(false);
31
+ expect(() => clearPainFlag(workspaceDir)).not.toThrow();
32
+ });
33
+
34
+ it('should export correct filename constant', () => {
35
+ expect(PAIN_FLAG_FILENAME).toBe('.pain_flag');
36
+ });
37
+ });