principles-disciple 1.41.0 → 1.43.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.
Files changed (95) hide show
  1. package/.planning/codebase/ARCHITECTURE.md +157 -0
  2. package/.planning/codebase/CONCERNS.md +145 -0
  3. package/.planning/codebase/CONVENTIONS.md +148 -0
  4. package/.planning/codebase/INTEGRATIONS.md +81 -0
  5. package/.planning/codebase/STACK.md +87 -0
  6. package/.planning/codebase/STRUCTURE.md +193 -0
  7. package/.planning/codebase/TESTING.md +243 -0
  8. package/openclaw.plugin.json +1 -1
  9. package/package.json +1 -1
  10. package/src/commands/archive-impl.ts +5 -3
  11. package/src/commands/context.ts +1 -0
  12. package/src/commands/disable-impl.ts +1 -1
  13. package/src/commands/evolution-status.ts +2 -2
  14. package/src/commands/pain.ts +12 -5
  15. package/src/commands/principle-rollback.ts +1 -1
  16. package/src/commands/promote-impl.ts +13 -7
  17. package/src/commands/rollback.ts +10 -4
  18. package/src/commands/samples.ts +1 -1
  19. package/src/commands/thinking-os.ts +1 -0
  20. package/src/commands/workflow-debug.ts +1 -1
  21. package/src/core/config.ts +1 -0
  22. package/src/core/dictionary.ts +1 -0
  23. package/src/core/event-log.ts +8 -6
  24. package/src/core/evolution-types.ts +33 -1
  25. package/src/core/external-training-contract.ts +1 -1
  26. package/src/core/merge-gate-audit.ts +3 -3
  27. package/src/core/nocturnal-arbiter.ts +1 -1
  28. package/src/core/nocturnal-compliance.ts +21 -21
  29. package/src/core/nocturnal-executability.ts +1 -1
  30. package/src/core/nocturnal-reasoning-deriver.ts +4 -4
  31. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  32. package/src/core/nocturnal-snapshot-contract.ts +1 -1
  33. package/src/core/pain-context-extractor.ts +2 -2
  34. package/src/core/path-resolver.ts +1 -0
  35. package/src/core/pd-task-reconciler.ts +1 -0
  36. package/src/core/pd-task-service.ts +1 -1
  37. package/src/core/pd-task-store.ts +1 -0
  38. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  39. package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
  40. package/src/core/principle-training-state.ts +2 -2
  41. package/src/core/principle-tree-migration.ts +1 -1
  42. package/src/core/replay-engine.ts +1 -0
  43. package/src/core/risk-calculator.ts +2 -1
  44. package/src/core/rule-host.ts +1 -1
  45. package/src/core/session-tracker.ts +1 -0
  46. package/src/core/shadow-observation-registry.ts +1 -1
  47. package/src/core/thinking-models.ts +1 -1
  48. package/src/core/thinking-os-parser.ts +1 -1
  49. package/src/core/trajectory.ts +2 -0
  50. package/src/hooks/bash-risk.ts +2 -2
  51. package/src/hooks/edit-verification.ts +3 -3
  52. package/src/hooks/gate.ts +8 -8
  53. package/src/hooks/gfi-gate.ts +2 -2
  54. package/src/hooks/lifecycle-routing.ts +1 -1
  55. package/src/hooks/message-sanitize.ts +18 -5
  56. package/src/hooks/pain.ts +2 -2
  57. package/src/hooks/progressive-trust-gate.ts +3 -3
  58. package/src/hooks/prompt.ts +17 -4
  59. package/src/hooks/subagent.ts +2 -3
  60. package/src/hooks/thinking-checkpoint.ts +1 -1
  61. package/src/http/principles-console-route.ts +21 -4
  62. package/src/service/central-database.ts +3 -2
  63. package/src/service/central-health-service.ts +2 -1
  64. package/src/service/central-overview-service.ts +3 -2
  65. package/src/service/control-ui-query-service.ts +2 -2
  66. package/src/service/event-log-auditor.ts +2 -2
  67. package/src/service/evolution-query-service.ts +1 -1
  68. package/src/service/evolution-worker.ts +96 -370
  69. package/src/service/health-query-service.ts +11 -10
  70. package/src/service/monitoring-query-service.ts +4 -4
  71. package/src/service/nocturnal-target-selector.ts +2 -2
  72. package/src/service/queue-io.ts +375 -0
  73. package/src/service/queue-migration.ts +122 -0
  74. package/src/service/runtime-summary-service.ts +1 -1
  75. package/src/service/sleep-cycle.ts +157 -0
  76. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
  77. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  78. package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
  79. package/src/service/subagent-workflow/workflow-store.ts +3 -2
  80. package/src/service/workflow-watchdog.ts +168 -0
  81. package/src/tools/critique-prompt.ts +1 -1
  82. package/src/tools/deep-reflect.ts +22 -11
  83. package/src/tools/model-index.ts +1 -1
  84. package/src/types/event-payload.ts +80 -0
  85. package/src/types/queue.ts +70 -0
  86. package/src/utils/file-lock.ts +2 -2
  87. package/src/utils/io.ts +11 -3
  88. package/tests/core/evolution-migration.test.ts +325 -1
  89. package/tests/core/queue-purge.test.ts +337 -0
  90. package/tests/fixtures/legacy-queue-v1.json +74 -0
  91. package/tests/queue/async-lock.test.ts +200 -0
  92. package/tests/service/evolution-worker.queue.test.ts +296 -0
  93. package/tests/service/queue-io.test.ts +229 -0
  94. package/tests/service/queue-migration.test.ts +147 -0
  95. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Unit tests for queue-io.ts — queue persistence layer.
3
+ */
4
+
5
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import {
10
+ loadEvolutionQueue,
11
+ saveEvolutionQueue,
12
+ withQueueLock,
13
+ acquireQueueLock,
14
+ EVOLUTION_QUEUE_LOCK_SUFFIX,
15
+ LOCK_MAX_RETRIES,
16
+ LOCK_RETRY_DELAY_MS,
17
+ LOCK_STALE_MS,
18
+ readRecentPainContext,
19
+ } from '../../src/service/queue-io.js';
20
+ import { readPainFlagContract } from '../../src/core/pain.js';
21
+
22
+ // Mock readPainFlagContract for readRecentPainContext tests
23
+ vi.mock('../../src/core/pain.js', () => ({
24
+ readPainFlagContract: vi.fn(),
25
+ }));
26
+
27
+ let tmpDir: string;
28
+ beforeEach(() => {
29
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-io-test-'));
30
+ });
31
+ afterEach(() => {
32
+ fs.rmSync(tmpDir, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('constants', () => {
36
+ it('exports EVOLUTION_QUEUE_LOCK_SUFFIX as .lock', () => {
37
+ expect(EVOLUTION_QUEUE_LOCK_SUFFIX).toBe('.lock');
38
+ });
39
+
40
+ it('exports LOCK_MAX_RETRIES as 50', () => {
41
+ expect(LOCK_MAX_RETRIES).toBe(50);
42
+ });
43
+
44
+ it('exports LOCK_RETRY_DELAY_MS as 50', () => {
45
+ expect(LOCK_RETRY_DELAY_MS).toBe(50);
46
+ });
47
+
48
+ it('exports LOCK_STALE_MS as 30_000', () => {
49
+ expect(LOCK_STALE_MS).toBe(30_000);
50
+ });
51
+ });
52
+
53
+ describe('loadEvolutionQueue', () => {
54
+ it('returns empty array when file does not exist', () => {
55
+ const result = loadEvolutionQueue(path.join(tmpDir, 'nonexistent.json'));
56
+ expect(result).toEqual([]);
57
+ });
58
+
59
+ it('migrates legacy queue items to V2 schema', () => {
60
+ const legacyFile = path.join(tmpDir, 'legacy-queue.json');
61
+ fs.writeFileSync(legacyFile, JSON.stringify([
62
+ { id: 'item-1', score: 75, source: 'tool_failure', reason: 'test', timestamp: '2024-01-01T00:00:00Z' },
63
+ ]));
64
+ const result = loadEvolutionQueue(legacyFile);
65
+ expect(result).toHaveLength(1);
66
+ expect(result[0].id).toBe('item-1');
67
+ expect(result[0].taskKind).toBe('pain_diagnosis'); // migrated default
68
+ expect(result[0].priority).toBe('medium');
69
+ expect(result[0].retryCount).toBe(0);
70
+ });
71
+
72
+ it('returns V2 queue items unchanged', () => {
73
+ const v2File = path.join(tmpDir, 'v2-queue.json');
74
+ const v2Item = {
75
+ id: 'item-2',
76
+ taskKind: 'principle_generation',
77
+ priority: 'high',
78
+ source: 'correction_keyword',
79
+ traceId: 'abc123',
80
+ task: 'Generate a principle',
81
+ score: 90,
82
+ reason: 'test',
83
+ timestamp: '2024-01-01T00:00:00Z',
84
+ status: 'pending',
85
+ resolution: undefined,
86
+ session_id: 'sess-1',
87
+ agent_id: 'agent-1',
88
+ retryCount: 0,
89
+ maxRetries: 3,
90
+ lastError: undefined,
91
+ resultRef: undefined,
92
+ };
93
+ fs.writeFileSync(v2File, JSON.stringify([v2Item]));
94
+ const result = loadEvolutionQueue(v2File);
95
+ expect(result).toHaveLength(1);
96
+ expect(result[0]).toMatchObject({ id: 'item-2', taskKind: 'principle_generation', priority: 'high' });
97
+ });
98
+
99
+ it('recovers with empty array when file contains corrupted JSON', () => {
100
+ const corruptFile = path.join(tmpDir, 'corrupt-queue.json');
101
+ fs.writeFileSync(corruptFile, '{ invalid json }', 'utf8');
102
+ const result = loadEvolutionQueue(corruptFile);
103
+ // Should recover gracefully with empty array (warns but doesn't throw)
104
+ expect(result).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe('saveEvolutionQueue', () => {
109
+ it('writes queue to file as formatted JSON', () => {
110
+ const outFile = path.join(tmpDir, 'saved-queue.json');
111
+ const queue = [
112
+ { id: 'item-1', taskKind: 'pain_diagnosis', priority: 'medium', source: 'test', traceId: '', task: '', score: 50, reason: 'test', timestamp: '2024-01-01T00:00:00Z', status: 'pending', resolution: undefined, session_id: '', agent_id: '', retryCount: 0, maxRetries: 3, lastError: undefined, resultRef: undefined },
113
+ ];
114
+ saveEvolutionQueue(outFile, queue);
115
+ const parsed = JSON.parse(fs.readFileSync(outFile, 'utf8'));
116
+ expect(parsed).toHaveLength(1);
117
+ expect(parsed[0].id).toBe('item-1');
118
+ });
119
+
120
+ it('overwrites existing file', () => {
121
+ const outFile = path.join(tmpDir, 'overwrite-queue.json');
122
+ fs.writeFileSync(outFile, JSON.stringify([{ id: 'old' }]));
123
+ saveEvolutionQueue(outFile, []);
124
+ const parsed = JSON.parse(fs.readFileSync(outFile, 'utf8'));
125
+ expect(parsed).toHaveLength(0);
126
+ });
127
+ });
128
+
129
+ describe('acquireQueueLock', () => {
130
+ it('acquires and releases lock on temp file', async () => {
131
+ const lockFile = path.join(tmpDir, 'test-lock-file');
132
+ const release = await acquireQueueLock(lockFile, undefined, '.lock');
133
+ expect(typeof release).toBe('function');
134
+
135
+ // Release should not throw
136
+ release();
137
+ });
138
+
139
+ it('acquired lock release function is callable multiple times (idempotent)', async () => {
140
+ const lockFile = path.join(tmpDir, 'idempotent-release');
141
+ const release = await acquireQueueLock(lockFile, undefined, '.lock');
142
+ release();
143
+ // Second release should not throw
144
+ release();
145
+ });
146
+ });
147
+
148
+ describe('withQueueLock RAII', () => {
149
+ it('releases lock after fn completes normally', async () => {
150
+ const lockFile = path.join(tmpDir, 'raii-normal');
151
+ let executed = false;
152
+ await withQueueLock(lockFile, undefined, 'test-scope', async () => {
153
+ executed = true;
154
+ });
155
+ expect(executed).toBe(true);
156
+
157
+ // Now re-acquire should succeed (lock was released)
158
+ const release = await acquireQueueLock(lockFile, undefined, '.lock');
159
+ release();
160
+ });
161
+
162
+ it('releases lock after fn throws', async () => {
163
+ const lockFile = path.join(tmpDir, 'raii-throws');
164
+
165
+ await expect(
166
+ withQueueLock(lockFile, undefined, 'test-scope', async () => {
167
+ throw new Error('boom');
168
+ }),
169
+ ).rejects.toThrow('boom');
170
+
171
+ // Lock should be released — re-acquire should succeed
172
+ const release = await acquireQueueLock(lockFile, undefined, '.lock');
173
+ release();
174
+ });
175
+ });
176
+
177
+ describe('readRecentPainContext', () => {
178
+ // readPainFlagContract is mocked via vi.mock above
179
+ const mockWctx = { workspaceDir: '/fake/workspace' } as any;
180
+
181
+ it('returns null context when contract status is not valid', () => {
182
+ vi.mocked(readPainFlagContract).mockReturnValueOnce({
183
+ status: 'missing',
184
+ format: 'missing',
185
+ data: {},
186
+ missingFields: [],
187
+ } as any);
188
+ const result = readRecentPainContext(mockWctx);
189
+ expect(result.mostRecent).toBeNull();
190
+ expect(result.recentPainCount).toBe(0);
191
+ });
192
+
193
+ it('returns null context when score parses to 0', () => {
194
+ vi.mocked(readPainFlagContract).mockReturnValueOnce({
195
+ status: 'valid',
196
+ format: 'kv',
197
+ data: { score: '0', source: 'tool_failure', reason: 'err', time: '2026-04-14T00:00:00Z', session_id: 's1' },
198
+ missingFields: [],
199
+ } as any);
200
+ const result = readRecentPainContext(mockWctx);
201
+ expect(result.mostRecent).toBeNull();
202
+ });
203
+
204
+ it('returns mostRecent with valid score > 0', () => {
205
+ vi.mocked(readPainFlagContract).mockReturnValueOnce({
206
+ status: 'valid',
207
+ format: 'kv',
208
+ data: { score: '75', source: 'tool_failure', reason: 'File write failed', time: '2026-04-14T10:00:00Z', session_id: 'sess-001' },
209
+ missingFields: [],
210
+ } as any);
211
+ const result = readRecentPainContext(mockWctx);
212
+ expect(result.mostRecent).not.toBeNull();
213
+ expect(result.mostRecent!.score).toBe(75);
214
+ expect(result.mostRecent!.source).toBe('tool_failure');
215
+ expect(result.recentPainCount).toBe(1);
216
+ expect(result.recentMaxPainScore).toBe(75);
217
+ });
218
+
219
+ it('returns null context when contract data is empty object', () => {
220
+ vi.mocked(readPainFlagContract).mockReturnValueOnce({
221
+ status: 'valid',
222
+ format: 'empty',
223
+ data: {},
224
+ missingFields: [],
225
+ } as any);
226
+ const result = readRecentPainContext(mockWctx);
227
+ expect(result.mostRecent).toBeNull();
228
+ });
229
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Unit tests for queue-migration.ts — pure data transformation functions.
3
+ * No I/O, no timers needed.
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import {
8
+ migrateToV2,
9
+ isLegacyQueueItem,
10
+ migrateQueueToV2,
11
+ LegacyEvolutionQueueItem,
12
+ DEFAULT_TASK_KIND,
13
+ DEFAULT_PRIORITY,
14
+ DEFAULT_MAX_RETRIES,
15
+ } from '../../src/service/queue-migration.js';
16
+
17
+ describe('isLegacyQueueItem', () => {
18
+ it('returns false for V2 item with taskKind', () => {
19
+ expect(isLegacyQueueItem({ id: 'x', taskKind: 'pain_diagnosis' })).toBe(false);
20
+ });
21
+
22
+ it('returns true for legacy item without taskKind', () => {
23
+ expect(isLegacyQueueItem({ id: 'x', score: 50, source: 'test' })).toBe(true);
24
+ });
25
+
26
+ it('returns false/falsy for null', () => {
27
+ expect(isLegacyQueueItem(null as any)).toBeFalsy();
28
+ });
29
+
30
+ it('returns true for empty object (no taskKind)', () => {
31
+ expect(isLegacyQueueItem({})).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe('migrateToV2 defaults', () => {
36
+ it('applies DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES for minimal legacy item', () => {
37
+ const legacy: LegacyEvolutionQueueItem = {
38
+ id: 'item-1',
39
+ score: 75,
40
+ source: 'tool_failure',
41
+ reason: 'Tool write failed',
42
+ timestamp: '2026-04-10T08:30:00.000Z',
43
+ };
44
+ const result = migrateToV2(legacy);
45
+ expect(result.taskKind).toBe(DEFAULT_TASK_KIND);
46
+ expect(result.priority).toBe(DEFAULT_PRIORITY);
47
+ expect(result.maxRetries).toBe(DEFAULT_MAX_RETRIES);
48
+ expect(result.retryCount).toBe(0);
49
+ });
50
+
51
+ it('preserves existing taskKind, priority, maxRetries when provided', () => {
52
+ const legacy: LegacyEvolutionQueueItem = {
53
+ id: 'item-2',
54
+ taskKind: 'sleep_reflection',
55
+ priority: 'high',
56
+ maxRetries: 5,
57
+ score: 80,
58
+ source: 'user_frustration',
59
+ reason: 'User corrected the agent',
60
+ timestamp: '2026-04-11T10:00:00.000Z',
61
+ };
62
+ const result = migrateToV2(legacy);
63
+ expect(result.taskKind).toBe('sleep_reflection');
64
+ expect(result.priority).toBe('high');
65
+ expect(result.maxRetries).toBe(5);
66
+ });
67
+
68
+ it('preserves all optional fields through migration', () => {
69
+ const legacy: LegacyEvolutionQueueItem = {
70
+ id: 'item-3',
71
+ task: 'Diagnose pain',
72
+ score: 90,
73
+ source: 'pain_signal',
74
+ reason: 'Test pain',
75
+ timestamp: '2026-04-12T00:00:00.000Z',
76
+ enqueued_at: '2026-04-12T00:00:01.000Z',
77
+ started_at: '2026-04-12T00:05:00.000Z',
78
+ completed_at: '2026-04-12T00:10:00.000Z',
79
+ assigned_session_key: 'session-key-123',
80
+ trigger_text_preview: 'Some trigger text',
81
+ status: 'completed',
82
+ resolution: 'success',
83
+ session_id: 'session-456',
84
+ agent_id: 'agent-789',
85
+ traceId: 'trace-abc',
86
+ taskKind: 'keyword_optimization',
87
+ priority: 'low',
88
+ retryCount: 2,
89
+ maxRetries: 4,
90
+ lastError: undefined,
91
+ resultRef: 'result-ref-001',
92
+ };
93
+ const result = migrateToV2(legacy);
94
+ expect(result.id).toBe('item-3');
95
+ expect(result.task).toBe('Diagnose pain');
96
+ expect(result.score).toBe(90);
97
+ expect(result.source).toBe('pain_signal');
98
+ expect(result.enqueued_at).toBe('2026-04-12T00:00:01.000Z');
99
+ expect(result.started_at).toBe('2026-04-12T00:05:00.000Z');
100
+ expect(result.completed_at).toBe('2026-04-12T00:10:00.000Z');
101
+ expect(result.assigned_session_key).toBe('session-key-123');
102
+ expect(result.trigger_text_preview).toBe('Some trigger text');
103
+ expect(result.status).toBe('completed');
104
+ expect(result.resolution).toBe('success');
105
+ expect(result.session_id).toBe('session-456');
106
+ expect(result.agent_id).toBe('agent-789');
107
+ expect(result.traceId).toBe('trace-abc');
108
+ expect(result.taskKind).toBe('keyword_optimization');
109
+ expect(result.priority).toBe('low');
110
+ expect(result.retryCount).toBe(2);
111
+ expect(result.maxRetries).toBe(4);
112
+ expect(result.lastError).toBeUndefined();
113
+ expect(result.resultRef).toBe('result-ref-001');
114
+ });
115
+ });
116
+
117
+ describe('migrateQueueToV2', () => {
118
+ it('migrates array with mixed legacy and V2 items correctly', () => {
119
+ const queue = [
120
+ { id: 'legacy-1', score: 50, source: 'a', reason: 'r', timestamp: '2026-04-01T00:00:00.000Z' },
121
+ { id: 'v2-1', taskKind: 'sleep_reflection', priority: 'high', source: 'b', score: 60, reason: 'r', timestamp: '2026-04-01T00:00:00.000Z' },
122
+ { id: 'legacy-2', score: 70, source: 'c', reason: 'r', timestamp: '2026-04-01T00:00:00.000Z' },
123
+ ];
124
+ const result = migrateQueueToV2(queue as any);
125
+ expect(result[0].taskKind).toBe('pain_diagnosis'); // migrated
126
+ expect(result[1].taskKind).toBe('sleep_reflection'); // V2 unchanged
127
+ expect(result[2].taskKind).toBe('pain_diagnosis'); // migrated
128
+ expect(result[0].priority).toBe('medium');
129
+ expect(result[1].priority).toBe('high');
130
+ expect(result[2].priority).toBe('medium');
131
+ });
132
+
133
+ it('returns V2 items unchanged (as EvolutionQueueItem[])', () => {
134
+ const v2Items = [
135
+ { id: 'v2-1', taskKind: 'pain_diagnosis', priority: 'medium', source: 'a', score: 55, reason: 'r', timestamp: '2026-04-01T00:00:00.000Z', status: 'pending', retryCount: 0, maxRetries: 3 },
136
+ { id: 'v2-2', taskKind: 'sleep_reflection', priority: 'high', source: 'b', score: 65, reason: 'r', timestamp: '2026-04-01T00:00:00.000Z', status: 'in_progress', retryCount: 1, maxRetries: 3 },
137
+ ];
138
+ const result = migrateQueueToV2(v2Items as any);
139
+ expect(result).toHaveLength(2);
140
+ expect(result[0].id).toBe('v2-1');
141
+ expect(result[1].id).toBe('v2-2');
142
+ });
143
+
144
+ it('returns empty array for empty input', () => {
145
+ expect(migrateQueueToV2([])).toHaveLength(0);
146
+ });
147
+ });