principles-disciple 1.10.0 → 1.12.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 (230) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +66 -0
  3. package/esbuild.config.js +1 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -3
  6. package/run-nocturnal.mjs +30 -0
  7. package/scripts/db-migrate.mjs +170 -0
  8. package/scripts/sync-plugin.mjs +94 -5
  9. package/src/commands/archive-impl.ts +136 -0
  10. package/src/commands/capabilities.ts +4 -2
  11. package/src/commands/context.ts +5 -1
  12. package/src/commands/disable-impl.ts +151 -0
  13. package/src/commands/evolution-status.ts +64 -19
  14. package/src/commands/export.ts +8 -6
  15. package/src/commands/focus.ts +8 -20
  16. package/src/commands/nocturnal-review.ts +5 -7
  17. package/src/commands/nocturnal-rollout.ts +1 -12
  18. package/src/commands/nocturnal-train.ts +17 -42
  19. package/src/commands/pain.ts +10 -5
  20. package/src/commands/principle-rollback.ts +4 -2
  21. package/src/commands/promote-impl.ts +274 -0
  22. package/src/commands/rollback-impl.ts +234 -0
  23. package/src/commands/rollback.ts +6 -3
  24. package/src/commands/samples.ts +2 -0
  25. package/src/commands/thinking-os.ts +3 -4
  26. package/src/commands/workflow-debug.ts +2 -1
  27. package/src/config/errors.ts +1 -0
  28. package/src/core/AGENTS.md +34 -0
  29. package/src/core/adaptive-thresholds.ts +4 -3
  30. package/src/core/code-implementation-storage.ts +241 -0
  31. package/src/core/config.ts +5 -2
  32. package/src/core/control-ui-db.ts +29 -10
  33. package/src/core/detection-funnel.ts +12 -7
  34. package/src/core/diagnostician-task-store.ts +156 -0
  35. package/src/core/dictionary.ts +4 -4
  36. package/src/core/empathy-keyword-matcher.ts +7 -3
  37. package/src/core/empathy-types.ts +13 -2
  38. package/src/core/event-log.ts +14 -6
  39. package/src/core/evolution-engine.ts +27 -31
  40. package/src/core/evolution-logger.ts +3 -2
  41. package/src/core/evolution-reducer.ts +83 -31
  42. package/src/core/evolution-types.ts +10 -0
  43. package/src/core/external-training-contract.ts +1 -0
  44. package/src/core/focus-history.ts +38 -24
  45. package/src/core/hygiene/tracker.ts +10 -6
  46. package/src/core/init.ts +5 -2
  47. package/src/core/migration.ts +3 -3
  48. package/src/core/model-deployment-registry.ts +6 -4
  49. package/src/core/model-training-registry.ts +5 -3
  50. package/src/core/nocturnal-arbiter.ts +13 -14
  51. package/src/core/nocturnal-artifact-lineage.ts +117 -0
  52. package/src/core/nocturnal-artificer.ts +257 -0
  53. package/src/core/nocturnal-candidate-scoring.ts +4 -2
  54. package/src/core/nocturnal-compliance.ts +67 -19
  55. package/src/core/nocturnal-dataset.ts +95 -2
  56. package/src/core/nocturnal-executability.ts +2 -3
  57. package/src/core/nocturnal-export.ts +6 -3
  58. package/src/core/nocturnal-rule-implementation-validator.ts +245 -0
  59. package/src/core/nocturnal-trajectory-extractor.ts +10 -3
  60. package/src/core/nocturnal-trinity.ts +300 -57
  61. package/src/core/pain-context-extractor.ts +29 -15
  62. package/src/core/pain.ts +7 -5
  63. package/src/core/path-resolver.ts +16 -15
  64. package/src/core/paths.ts +2 -1
  65. package/src/core/pd-task-reconciler.ts +463 -0
  66. package/src/core/pd-task-service.ts +42 -0
  67. package/src/core/pd-task-store.ts +77 -0
  68. package/src/core/pd-task-types.ts +128 -0
  69. package/src/core/principle-internalization/deprecated-readiness.ts +91 -0
  70. package/src/core/principle-internalization/internalization-routing-policy.ts +208 -0
  71. package/src/core/principle-internalization/lifecycle-metrics.ts +149 -0
  72. package/src/core/principle-internalization/lifecycle-read-model.ts +243 -0
  73. package/src/core/principle-internalization/lifecycle-refresh.ts +11 -0
  74. package/src/core/principle-internalization/principle-lifecycle-service.ts +167 -0
  75. package/src/core/principle-training-state.ts +95 -370
  76. package/src/core/principle-tree-ledger.ts +713 -0
  77. package/src/core/profile.ts +3 -1
  78. package/src/core/promotion-gate.ts +14 -18
  79. package/src/core/replay-engine.ts +562 -0
  80. package/src/core/risk-calculator.ts +6 -4
  81. package/src/core/rule-host-helpers.ts +39 -0
  82. package/src/core/rule-host-types.ts +82 -0
  83. package/src/core/rule-host.ts +245 -0
  84. package/src/core/rule-implementation-runtime.ts +38 -0
  85. package/src/core/schema/db-types.ts +16 -0
  86. package/src/core/schema/index.ts +26 -0
  87. package/src/core/schema/migration-runner.ts +207 -0
  88. package/src/core/schema/migrations/001-init-trajectory.ts +211 -0
  89. package/src/core/schema/migrations/002-init-central.ts +122 -0
  90. package/src/core/schema/migrations/003-init-workflow.ts +55 -0
  91. package/src/core/schema/migrations/004-add-thinking-and-gfi.ts +74 -0
  92. package/src/core/schema/migrations/index.ts +31 -0
  93. package/src/core/schema/schema-definitions.ts +650 -0
  94. package/src/core/session-tracker.ts +6 -4
  95. package/src/core/shadow-observation-registry.ts +6 -3
  96. package/src/core/system-logger.ts +2 -2
  97. package/src/core/thinking-models.ts +182 -46
  98. package/src/core/thinking-os-parser.ts +164 -0
  99. package/src/core/training-program.ts +7 -7
  100. package/src/core/trajectory.ts +42 -36
  101. package/src/core/workspace-context.ts +77 -11
  102. package/src/core/workspace-dir-validation.ts +152 -0
  103. package/src/hooks/AGENTS.md +31 -0
  104. package/src/hooks/bash-risk.ts +3 -1
  105. package/src/hooks/edit-verification.ts +9 -5
  106. package/src/hooks/gate-block-helper.ts +5 -1
  107. package/src/hooks/gate.ts +152 -5
  108. package/src/hooks/gfi-gate.ts +9 -2
  109. package/src/hooks/lifecycle-routing.ts +124 -0
  110. package/src/hooks/lifecycle.ts +12 -12
  111. package/src/hooks/llm.ts +17 -109
  112. package/src/hooks/message-sanitize.ts +5 -3
  113. package/src/hooks/pain.ts +19 -15
  114. package/src/hooks/progressive-trust-gate.ts +7 -1
  115. package/src/hooks/prompt.ts +169 -60
  116. package/src/hooks/subagent.ts +5 -4
  117. package/src/hooks/thinking-checkpoint.ts +2 -0
  118. package/src/hooks/trajectory-collector.ts +15 -12
  119. package/src/http/principles-console-route.ts +31 -68
  120. package/src/i18n/commands.ts +2 -2
  121. package/src/index.ts +126 -40
  122. package/src/service/central-database.ts +131 -43
  123. package/src/service/central-health-service.ts +47 -0
  124. package/src/service/central-overview-service.ts +135 -0
  125. package/src/service/central-sync-service.ts +87 -0
  126. package/src/service/control-ui-query-service.ts +46 -36
  127. package/src/service/event-log-auditor.ts +261 -0
  128. package/src/service/evolution-query-service.ts +23 -22
  129. package/src/service/evolution-worker.ts +565 -261
  130. package/src/service/health-query-service.ts +213 -36
  131. package/src/service/nocturnal-runtime.ts +8 -4
  132. package/src/service/nocturnal-service.ts +499 -59
  133. package/src/service/nocturnal-target-selector.ts +5 -7
  134. package/src/service/runtime-summary-service.ts +2 -1
  135. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +25 -336
  136. package/src/service/subagent-workflow/dynamic-timeout.ts +30 -0
  137. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +48 -386
  138. package/src/service/subagent-workflow/index.ts +2 -0
  139. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +155 -285
  140. package/src/service/subagent-workflow/runtime-direct-driver.ts +114 -16
  141. package/src/service/subagent-workflow/subagent-error-utils.ts +25 -0
  142. package/src/service/subagent-workflow/types.ts +9 -4
  143. package/src/service/subagent-workflow/workflow-manager-base.ts +573 -0
  144. package/src/service/subagent-workflow/workflow-store.ts +71 -11
  145. package/src/service/trajectory-service.ts +2 -1
  146. package/src/tools/critique-prompt.ts +1 -1
  147. package/src/tools/deep-reflect.ts +175 -209
  148. package/src/tools/model-index.ts +2 -1
  149. package/src/types/event-types.ts +2 -2
  150. package/src/types/principle-tree-schema.ts +29 -23
  151. package/src/utils/file-lock.ts +5 -3
  152. package/src/utils/io.ts +5 -2
  153. package/src/utils/nlp.ts +5 -46
  154. package/src/utils/node-vm-polyfill.ts +11 -0
  155. package/src/utils/plugin-logger.ts +2 -0
  156. package/src/utils/retry.ts +572 -0
  157. package/src/utils/subagent-probe.ts +1 -1
  158. package/templates/langs/en/core/AGENTS.md +0 -13
  159. package/templates/langs/en/core/SOUL.md +1 -31
  160. package/templates/langs/en/core/TOOLS.md +0 -4
  161. package/templates/langs/en/principles/THINKING_OS.md +64 -0
  162. package/templates/langs/en/skills/admin/SKILL.md +0 -1
  163. package/templates/langs/en/skills/evolution-framework-update/SKILL.md +1 -1
  164. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +18 -5
  165. package/templates/langs/zh/core/AGENTS.md +0 -22
  166. package/templates/langs/zh/core/SOUL.md +1 -31
  167. package/templates/langs/zh/core/TOOLS.md +0 -4
  168. package/templates/langs/zh/principles/THINKING_OS.md +64 -0
  169. package/templates/langs/zh/skills/admin/SKILL.md +0 -1
  170. package/templates/langs/zh/skills/evolution-framework-update/SKILL.md +1 -1
  171. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +25 -4
  172. package/tests/commands/evolution-status.test.ts +119 -0
  173. package/tests/commands/implementation-lifecycle.test.ts +362 -0
  174. package/tests/core/code-implementation-storage.test.ts +398 -0
  175. package/tests/core/evolution-reducer.detector-metadata.test.ts +28 -28
  176. package/tests/core/nocturnal-artifact-lineage.test.ts +53 -0
  177. package/tests/core/nocturnal-artificer.test.ts +241 -0
  178. package/tests/core/nocturnal-compliance-p-principles.test.ts +133 -0
  179. package/tests/core/nocturnal-rule-implementation-validator.test.ts +127 -0
  180. package/tests/core/pd-task-store.test.ts +126 -0
  181. package/tests/core/principle-internalization/deprecated-readiness.test.ts +193 -0
  182. package/tests/core/principle-internalization/internalization-routing-policy.test.ts +212 -0
  183. package/tests/core/principle-internalization/lifecycle-metrics.test.ts +350 -0
  184. package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +211 -0
  185. package/tests/core/principle-training-state.test.ts +228 -1
  186. package/tests/core/principle-tree-ledger.test.ts +423 -0
  187. package/tests/core/regression-v1-9-1.test.ts +265 -0
  188. package/tests/core/replay-engine.test.ts +234 -0
  189. package/tests/core/rule-host-helpers.test.ts +120 -0
  190. package/tests/core/rule-host.test.ts +389 -0
  191. package/tests/core/rule-implementation-runtime.test.ts +64 -0
  192. package/tests/core/workspace-context.test.ts +53 -0
  193. package/tests/core/workspace-dir-validation.test.ts +272 -0
  194. package/tests/hooks/gate-rule-host-pipeline.test.ts +385 -0
  195. package/tests/hooks/pain.test.ts +74 -10
  196. package/tests/hooks/prompt.test.ts +63 -1
  197. package/tests/integration/principle-lifecycle.e2e.test.ts +197 -0
  198. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +211 -0
  199. package/tests/service/data-endpoints-regression.test.ts +834 -0
  200. package/tests/service/evolution-worker.test.ts +0 -123
  201. package/tests/service/nocturnal-service-code-candidate.test.ts +330 -0
  202. package/tests/utils/nlp.test.ts +1 -19
  203. package/tests/utils/retry.test.ts +327 -0
  204. package/ui/src/App.tsx +1 -1
  205. package/ui/src/api.ts +4 -0
  206. package/ui/src/charts.tsx +366 -0
  207. package/ui/src/components/WorkspaceConfig.tsx +107 -75
  208. package/ui/src/i18n/ui.ts +55 -22
  209. package/ui/src/pages/OverviewPage.tsx +441 -81
  210. package/ui/src/styles.css +43 -0
  211. package/ui/src/types.ts +17 -1
  212. package/src/agents/nocturnal-dreamer.md +0 -152
  213. package/src/agents/nocturnal-philosopher.md +0 -138
  214. package/src/agents/nocturnal-reflector.md +0 -126
  215. package/src/agents/nocturnal-scribe.md +0 -164
  216. package/templates/workspace/.principles/00-kernel.md +0 -51
  217. package/templates/workspace/.principles/DECISION_POLICY.json +0 -44
  218. package/templates/workspace/.principles/PRINCIPLES.md +0 -20
  219. package/templates/workspace/.principles/PROFILE.json +0 -54
  220. package/templates/workspace/.principles/PROFILE.schema.json +0 -56
  221. package/templates/workspace/.principles/THINKING_OS.md +0 -64
  222. package/templates/workspace/.principles/THINKING_OS_ARCHIVE.md +0 -7
  223. package/templates/workspace/.principles/THINKING_OS_CANDIDATES.md +0 -9
  224. package/templates/workspace/.principles/models/_INDEX.md +0 -27
  225. package/templates/workspace/.principles/models/first_principles.md +0 -62
  226. package/templates/workspace/.principles/models/marketing_4p.md +0 -52
  227. package/templates/workspace/.principles/models/porter_five.md +0 -63
  228. package/templates/workspace/.principles/models/swot.md +0 -60
  229. package/templates/workspace/.principles/models/user_story_map.md +0 -63
  230. package/templates/workspace/.state/WORKBOARD.json +0 -4
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Unit tests for workspace-dir-validation.ts
3
+ *
4
+ * Tests the core validation logic and 3-tier fallback strategy
5
+ * for resolving correct workspaceDir in tool hooks.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import * as os from 'os';
10
+ import { validateWorkspaceDir, resolveValidWorkspaceDir, logWorkspaceDirHealth } from '../../src/core/workspace-dir-validation.js';
11
+
12
+ const homeDir = os.homedir();
13
+
14
+ describe('validateWorkspaceDir', () => {
15
+ it('should return error for undefined', () => {
16
+ const result = validateWorkspaceDir(undefined);
17
+ expect(result).toBe('workspaceDir is undefined/null');
18
+ });
19
+
20
+ it('should return error for null (treated as undefined)', () => {
21
+ const result = validateWorkspaceDir(null as unknown as string);
22
+ expect(result).toBe('workspaceDir is undefined/null');
23
+ });
24
+
25
+ it('should return error for empty string (treated as falsy)', () => {
26
+ const result = validateWorkspaceDir('');
27
+ // Empty string is falsy, so it's treated like undefined
28
+ expect(result).toContain('undefined/null');
29
+ });
30
+
31
+ it('should return error for root directory', () => {
32
+ const result = validateWorkspaceDir('/');
33
+ expect(result).toContain('root or empty');
34
+ });
35
+
36
+ it('should return error for home directory', () => {
37
+ const result = validateWorkspaceDir(homeDir);
38
+ expect(result).toContain('equals home directory');
39
+ expect(result).toContain(homeDir);
40
+ });
41
+
42
+ it('should return error for home directory with trailing slash', () => {
43
+ const result = validateWorkspaceDir(`${homeDir}/`);
44
+ expect(result).toContain('home directory');
45
+ });
46
+
47
+ it('should return null (valid) for a proper workspace path', () => {
48
+ const result = validateWorkspaceDir('/home/user/projects/my-workspace');
49
+ expect(result).toBeNull();
50
+ });
51
+
52
+ it('should return null (valid) for a nested workspace path', () => {
53
+ const result = validateWorkspaceDir('/home/user/.openclaw/workspace-main');
54
+ expect(result).toBeNull();
55
+ });
56
+
57
+ it('should return null (valid) for temp directory', () => {
58
+ const result = validateWorkspaceDir('/tmp/test-workspace');
59
+ expect(result).toBeNull();
60
+ });
61
+
62
+ it('should return null (valid) for Windows-style path', () => {
63
+ // On Linux, Windows paths are just regular paths
64
+ const result = validateWorkspaceDir('C:\\Users\\test\\workspace');
65
+ expect(result).toBeNull();
66
+ });
67
+ });
68
+
69
+ describe('resolveValidWorkspaceDir', () => {
70
+ const mockLogger = {
71
+ warn: vi.fn(),
72
+ error: vi.fn(),
73
+ info: vi.fn(),
74
+ debug: vi.fn(),
75
+ };
76
+
77
+ const mockApi = {
78
+ runtime: {
79
+ agent: {
80
+ resolveAgentWorkspaceDir: vi.fn(),
81
+ },
82
+ },
83
+ config: {},
84
+ resolvePath: vi.fn(),
85
+ logger: mockLogger,
86
+ };
87
+
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ mockApi.resolvePath.mockReturnValue('/default/workspace');
91
+ mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue('/resolved/from/agent');
92
+ });
93
+
94
+ afterEach(() => {
95
+ vi.resetAllMocks();
96
+ });
97
+
98
+ it('should return ctx.workspaceDir when valid', () => {
99
+ const ctx = { workspaceDir: '/valid/workspace', agentId: 'agent-1' };
100
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
101
+
102
+ expect(result).toBe('/valid/workspace');
103
+ expect(mockLogger.warn).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('should warn and continue when ctx.workspaceDir is home directory', () => {
107
+ const ctx = { workspaceDir: homeDir, agentId: 'agent-1' };
108
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
109
+
110
+ // Should fall back to agentId resolution
111
+ expect(result).toBe('/resolved/from/agent');
112
+ expect(mockLogger.warn).toHaveBeenCalled();
113
+ });
114
+
115
+ it('should try agentId resolution when ctx.workspaceDir is undefined', () => {
116
+ const ctx = { workspaceDir: undefined, agentId: 'agent-1' };
117
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
118
+
119
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(mockApi.config, 'agent-1');
120
+ expect(result).toBe('/resolved/from/agent');
121
+ });
122
+
123
+ it('should fallback to resolvePath when agentId is undefined', () => {
124
+ const ctx = { workspaceDir: undefined, agentId: undefined };
125
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
126
+
127
+ expect(mockApi.resolvePath).toHaveBeenCalledWith('.');
128
+ expect(result).toBe('/default/workspace');
129
+ });
130
+
131
+ it('should fallback to resolvePath when agentId resolution fails', () => {
132
+ mockApi.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
133
+ throw new Error('Agent not found');
134
+ });
135
+
136
+ const ctx = { workspaceDir: undefined, agentId: 'unknown-agent' };
137
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
138
+
139
+ expect(mockApi.resolvePath).toHaveBeenCalledWith('.');
140
+ expect(result).toBe('/default/workspace');
141
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('failed to resolve from agentId'));
142
+ });
143
+
144
+ it('should fallback to resolvePath when agentId resolution returns home directory', () => {
145
+ mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
146
+
147
+ const ctx = { workspaceDir: undefined, agentId: 'agent-1' };
148
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
149
+
150
+ expect(mockApi.resolvePath).toHaveBeenCalledWith('.');
151
+ expect(result).toBe('/default/workspace');
152
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid'));
153
+ });
154
+
155
+ it('should warn when all fallbacks fail', () => {
156
+ mockApi.resolvePath.mockReturnValue(homeDir);
157
+
158
+ const ctx = { workspaceDir: undefined, agentId: undefined };
159
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
160
+
161
+ expect(result).toBe(homeDir);
162
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('FINAL FALLBACK'));
163
+ });
164
+
165
+ it('should use custom onWarning callback', () => {
166
+ const customWarning = vi.fn();
167
+
168
+ const ctx = { workspaceDir: homeDir, agentId: undefined };
169
+ mockApi.resolvePath.mockReturnValue('/valid/fallback');
170
+
171
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, {
172
+ source: 'test',
173
+ onWarning: customWarning,
174
+ });
175
+
176
+ expect(customWarning).toHaveBeenCalled();
177
+ });
178
+
179
+ it('should use default source "unknown" when not provided', () => {
180
+ const ctx = { workspaceDir: homeDir, agentId: undefined };
181
+ mockApi.resolvePath.mockReturnValue('/valid/fallback');
182
+
183
+ resolveValidWorkspaceDir(ctx, mockApi as any);
184
+
185
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('unknown:'));
186
+ });
187
+ });
188
+
189
+ describe('logWorkspaceDirHealth', () => {
190
+ const mockLogger = {
191
+ warn: vi.fn(),
192
+ error: vi.fn(),
193
+ info: vi.fn(),
194
+ debug: vi.fn(),
195
+ };
196
+
197
+ const mockApi = {
198
+ runtime: {
199
+ agent: {
200
+ resolveAgentWorkspaceDir: vi.fn().mockReturnValue('/valid/workspace'),
201
+ },
202
+ },
203
+ config: {},
204
+ resolvePath: vi.fn().mockReturnValue('/valid/workspace'),
205
+ logger: mockLogger,
206
+ };
207
+
208
+ beforeEach(() => {
209
+ vi.clearAllMocks();
210
+ });
211
+
212
+ it('should log info when workspaceDir is valid', () => {
213
+ const ctx = { workspaceDir: '/valid/workspace' };
214
+ logWorkspaceDirHealth(ctx, 'startup', mockApi as any);
215
+
216
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('✓'));
217
+ });
218
+
219
+ it('should log error when workspaceDir is invalid', () => {
220
+ mockApi.resolvePath.mockReturnValue(homeDir);
221
+
222
+ const ctx = { workspaceDir: undefined };
223
+ logWorkspaceDirHealth(ctx, 'startup', mockApi as any);
224
+
225
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(homeDir));
226
+ });
227
+ });
228
+
229
+ describe('Integration: 3-tier fallback chain', () => {
230
+ const mockLogger = {
231
+ warn: vi.fn(),
232
+ error: vi.fn(),
233
+ info: vi.fn(),
234
+ debug: vi.fn(),
235
+ };
236
+
237
+ it('should follow the complete fallback chain: ctx.workspaceDir -> agentId -> resolvePath', () => {
238
+ const mockApi = {
239
+ runtime: {
240
+ agent: {
241
+ resolveAgentWorkspaceDir: vi.fn().mockReturnValue('/workspace/from-agent'),
242
+ },
243
+ },
244
+ config: {},
245
+ resolvePath: vi.fn().mockReturnValue('/workspace/from-resolvePath'),
246
+ logger: mockLogger,
247
+ };
248
+
249
+ // Case 1: ctx.workspaceDir is valid - use it directly
250
+ const ctx1 = { workspaceDir: '/workspace/from-ctx', agentId: 'agent-1' };
251
+ const result1 = resolveValidWorkspaceDir(ctx1, mockApi as any, { source: 'test' });
252
+ expect(result1).toBe('/workspace/from-ctx');
253
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).not.toHaveBeenCalled();
254
+
255
+ vi.clearAllMocks();
256
+
257
+ // Case 2: ctx.workspaceDir is invalid, agentId resolution works
258
+ const ctx2 = { workspaceDir: homeDir, agentId: 'agent-1' };
259
+ const result2 = resolveValidWorkspaceDir(ctx2, mockApi as any, { source: 'test' });
260
+ expect(result2).toBe('/workspace/from-agent');
261
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalled();
262
+
263
+ vi.clearAllMocks();
264
+
265
+ // Case 3: ctx.workspaceDir is invalid, agentId resolution returns invalid, use resolvePath
266
+ mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
267
+ const ctx3 = { workspaceDir: homeDir, agentId: 'agent-1' };
268
+ const result3 = resolveValidWorkspaceDir(ctx3, mockApi as any, { source: 'test' });
269
+ expect(result3).toBe('/workspace/from-resolvePath');
270
+ expect(mockApi.resolvePath).toHaveBeenCalledWith('.');
271
+ });
272
+ });
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Gate Rule Host Pipeline Integration Tests
3
+ *
4
+ * PURPOSE: Verify that the Rule Host is correctly wired into the gate chain
5
+ * between GFI and Progressive Gate, with correct ordering and behavior.
6
+ *
7
+ * Tests:
8
+ * 1. When GFI blocks, Rule Host is never called
9
+ * 2. When Rule Host blocks, Progressive Gate is never called
10
+ * 3. When Rule Host returns undefined (no active implementations), Progressive Gate runs normally
11
+ * 4. When Rule Host throws, gate continues to Progressive Gate (D-08)
12
+ * 5. Block result uses blockSource='rule-host'
13
+ * 6. Existing gate flow still works when no active implementations exist
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17
+ import { handleBeforeToolCall } from '../../src/hooks/gate.js';
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { WorkspaceContext } from '../../src/core/workspace-context.js';
21
+ import * as sessionTracker from '../../src/core/session-tracker.js';
22
+ import * as evolutionEngine from '../../src/core/evolution-engine.js';
23
+
24
+ // Mock fs
25
+ vi.mock('fs');
26
+
27
+ // Mock workspace context
28
+ vi.mock('../../src/core/workspace-context.js');
29
+
30
+ // Mock session tracker
31
+ vi.mock('../../src/core/session-tracker.js', () => ({
32
+ getSession: vi.fn(() => ({ currentGfi: 0 })),
33
+ trackBlock: vi.fn(),
34
+ hasRecentThinking: vi.fn(() => false),
35
+ }));
36
+
37
+ // Mock evolution engine
38
+ vi.mock('../../src/core/evolution-engine.js', async () => {
39
+ const actual = await vi.importActual('../../src/core/evolution-engine.js');
40
+ return {
41
+ ...actual,
42
+ checkEvolutionGate: vi.fn(() => ({ allowed: true, currentTier: 'SEED' })),
43
+ getEvolutionEngine: vi.fn(),
44
+ };
45
+ });
46
+
47
+ // Mock Rule Host module — controls RuleHost.evaluate behavior
48
+ // Use a shared mutable evaluate mock that tests can override
49
+ let _mockEvaluate: ReturnType<typeof vi.fn> = vi.fn().mockReturnValue(undefined);
50
+
51
+ vi.mock('../../src/core/rule-host.js', () => {
52
+ return {
53
+ RuleHost: vi.fn(function(this: any, _stateDir: string) {
54
+ this.evaluate = _mockEvaluate;
55
+ }),
56
+ };
57
+ });
58
+
59
+ // Mock ledger to avoid file reads
60
+ vi.mock('../../src/core/principle-tree-ledger.js', () => ({
61
+ loadLedger: vi.fn(),
62
+ listImplementationsByLifecycleState: vi.fn(() => []),
63
+ }));
64
+
65
+ import { RuleHost } from '../../src/core/rule-host.js';
66
+ import * as sessionTrackerModule from '../../src/core/session-tracker.js';
67
+ import * as evolutionEngineModule from '../../src/core/evolution-engine.js';
68
+
69
+ const MockedRuleHost = vi.mocked(RuleHost);
70
+
71
+ const mockEvolution = {
72
+ getTier: vi.fn().mockReturnValue(3),
73
+ getPoints: vi.fn().mockReturnValue(200),
74
+ };
75
+
76
+ describe('Gate Rule Host Pipeline Integration', () => {
77
+ const workspaceDir = '/mock/workspace';
78
+ const sessionId = 'test-session-rh';
79
+
80
+ const mockConfig = {
81
+ get: vi.fn().mockImplementation((key: string) => {
82
+ if (key === 'trust') return {
83
+ limits: { stage_2_max_lines: 50, stage_3_max_lines: 300 }
84
+ };
85
+ if (key === 'gfi_gate') return {
86
+ enabled: true,
87
+ thresholds: { low_risk_block: 70, high_risk_block: 40 },
88
+ bash_safe_patterns: ['^(ls|dir|pwd)$'],
89
+ bash_dangerous_patterns: ['rm\\s+-rf'],
90
+ };
91
+ return undefined;
92
+ })
93
+ };
94
+
95
+ const mockEventLog = {
96
+ recordGateBlock: vi.fn(),
97
+ recordPlanApproval: vi.fn(),
98
+ recordGateBypass: vi.fn(),
99
+ };
100
+
101
+ const mockTrajectory = {
102
+ recordGateBlock: vi.fn(),
103
+ };
104
+
105
+ const mockWctx = {
106
+ workspaceDir,
107
+ stateDir: '/mock/state',
108
+ config: mockConfig,
109
+ eventLog: mockEventLog,
110
+ trajectory: mockTrajectory,
111
+ evolution: mockEvolution,
112
+ resolve: vi.fn().mockImplementation((key: string) => {
113
+ if (key === 'PROFILE') return path.join(workspaceDir, '.principles', 'PROFILE.json');
114
+ if (key === 'PLAN') return path.join(workspaceDir, 'PLAN.md');
115
+ if (key === 'STATE_DIR') return path.join(workspaceDir, '.state');
116
+ if (typeof key === 'string' && !key.includes(':')) {
117
+ return path.join(workspaceDir, key);
118
+ }
119
+ return key;
120
+ }),
121
+ };
122
+
123
+ beforeEach(() => {
124
+ vi.clearAllMocks();
125
+ vi.useFakeTimers();
126
+ // Reset the shared evaluate mock to default (returns undefined)
127
+ _mockEvaluate = vi.fn().mockReturnValue(undefined);
128
+ MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
129
+ this.evaluate = _mockEvaluate;
130
+ });
131
+ vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue(mockWctx as any);
132
+ vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 0 } as any);
133
+ vi.mocked(sessionTrackerModule.trackBlock).mockImplementation(() => {});
134
+ vi.mocked(evolutionEngineModule.getEvolutionEngine).mockReturnValue(mockEvolution);
135
+ });
136
+
137
+ afterEach(() => {
138
+ vi.useRealTimers();
139
+ });
140
+
141
+ /**
142
+ * Helper: create a standard write event
143
+ */
144
+ function makeWriteEvent(overrides?: Partial<any>) {
145
+ return {
146
+ toolName: 'write',
147
+ params: {
148
+ file_path: 'src/test.ts',
149
+ content: 'const x = 1;',
150
+ },
151
+ ...overrides,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Helper: set up fs mocks for a profile with progressive gate enabled
157
+ */
158
+ function setupProfileMock(profileOverrides?: Record<string, unknown>) {
159
+ vi.mocked(fs.existsSync).mockImplementation((p: any) => {
160
+ if (typeof p === 'string' && p.includes('PROFILE.json')) return true;
161
+ return false;
162
+ });
163
+ vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
164
+ if (typeof p === 'string' && p.includes('PROFILE.json')) {
165
+ return JSON.stringify({
166
+ risk_paths: [],
167
+ progressive_gate: { enabled: true },
168
+ edit_verification: { enabled: true },
169
+ ...profileOverrides,
170
+ });
171
+ }
172
+ return '';
173
+ });
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+ // TEST 1: When GFI blocks, Rule Host is never called
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+ it('should not call Rule Host when GFI gate blocks', () => {
180
+ // Set high GFI to trigger GFI block
181
+ vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 85 } as any);
182
+ setupProfileMock();
183
+
184
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
185
+
186
+ // GFI should block
187
+ expect(result).toBeDefined();
188
+ expect(result?.block).toBe(true);
189
+ expect(result?.blockReason).toContain('GFI');
190
+
191
+ // RuleHost constructor should NOT have been called
192
+ // (GFI returns before reaching Rule Host evaluation)
193
+ expect(MockedRuleHost).not.toHaveBeenCalled();
194
+ });
195
+
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+ // TEST 2: When Rule Host blocks, Progressive Gate is never called
198
+ // ═══════════════════════════════════════════════════════════════════════════
199
+ it('should not reach Progressive Gate when Rule Host blocks', () => {
200
+ setupProfileMock();
201
+
202
+ // Mock RuleHost.evaluate to return a block
203
+ _mockEvaluate = vi.fn().mockReturnValue({
204
+ decision: 'block',
205
+ matched: true,
206
+ reason: 'Rule Host test block',
207
+ });
208
+ MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
209
+ this.evaluate = _mockEvaluate;
210
+ });
211
+
212
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
213
+
214
+ // Rule Host should block
215
+ expect(result).toBeDefined();
216
+ expect(result?.block).toBe(true);
217
+
218
+ // The block should come from rule-host
219
+ expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
220
+ sessionId,
221
+ expect.objectContaining({ blockSource: 'rule-host' })
222
+ );
223
+ });
224
+
225
+ // ═══════════════════════════════════════════════════════════════════════════
226
+ // TEST 3: When Rule Host returns undefined, Progressive Gate runs normally
227
+ // ═══════════════════════════════════════════════════════════════════════════
228
+ it('should continue to Progressive Gate when Rule Host returns undefined', () => {
229
+ setupProfileMock();
230
+
231
+ // Mock RuleHost.evaluate to return undefined (no active implementations)
232
+ _mockEvaluate = vi.fn().mockReturnValue(undefined);
233
+ MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
234
+ this.evaluate = _mockEvaluate;
235
+ });
236
+
237
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
238
+
239
+ // Should pass through (no block from any gate)
240
+ expect(result).toBeUndefined();
241
+
242
+ // Rule Host was called
243
+ expect(_mockEvaluate).toHaveBeenCalled();
244
+ });
245
+
246
+ // ═══════════════════════════════════════════════════════════════════════════
247
+ // TEST 4: When Rule Host throws, gate continues to Progressive Gate (D-08)
248
+ // ═══════════════════════════════════════════════════════════════════════════
249
+ it('should continue to Progressive Gate when Rule Host throws (D-08)', () => {
250
+ setupProfileMock();
251
+
252
+ // Mock RuleHost.evaluate to throw
253
+ _mockEvaluate = vi.fn().mockImplementation(() => {
254
+ throw new Error('Host internal error');
255
+ });
256
+ MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
257
+ this.evaluate = _mockEvaluate;
258
+ });
259
+
260
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
261
+
262
+ // Should pass through (host error is caught, degrades to Progressive Gate)
263
+ // Progressive Gate will pass for this low-risk operation
264
+ expect(result).toBeUndefined();
265
+ });
266
+
267
+ // ═══════════════════════════════════════════════════════════════════════════
268
+ // TEST 5: Block result uses blockSource='rule-host'
269
+ // ═══════════════════════════════════════════════════════════════════════════
270
+ it('should use blockSource=rule-host for Rule Host blocks', () => {
271
+ setupProfileMock();
272
+
273
+ _mockEvaluate = vi.fn().mockReturnValue({
274
+ decision: 'block',
275
+ matched: true,
276
+ reason: 'Dangerous file modification',
277
+ });
278
+ MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
279
+ this.evaluate = _mockEvaluate;
280
+ });
281
+
282
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
283
+
284
+ expect(result?.block).toBe(true);
285
+
286
+ // Verify recordGateBlockAndReturn was called with blockSource='rule-host'
287
+ expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
288
+ sessionId,
289
+ expect.objectContaining({
290
+ blockSource: 'rule-host',
291
+ reason: 'Dangerous file modification',
292
+ })
293
+ );
294
+ });
295
+
296
+ // ═══════════════════════════════════════════════════════════════════════════
297
+ // TEST 5b: requireApproval result uses blockSource='rule-host' with reason
298
+ // ═══════════════════════════════════════════════════════════════════════════
299
+ it('should use blockSource=rule-host with approval prefix for requireApproval', () => {
300
+ setupProfileMock();
301
+
302
+ _mockEvaluate = vi.fn().mockReturnValue({
303
+ decision: 'requireApproval',
304
+ matched: true,
305
+ reason: 'High-risk path requires approval',
306
+ });
307
+ MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
308
+ this.evaluate = _mockEvaluate;
309
+ });
310
+
311
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
312
+
313
+ expect(result?.block).toBe(true);
314
+ expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
315
+ sessionId,
316
+ expect.objectContaining({
317
+ blockSource: 'rule-host',
318
+ reason: expect.stringContaining('[Rule Host] Approval required'),
319
+ })
320
+ );
321
+ });
322
+
323
+ // ═══════════════════════════════════════════════════════════════════════════
324
+ // TEST 6: Existing gate flow still works when no active implementations exist
325
+ // ═══════════════════════════════════════════════════════════════════════════
326
+ it('should allow operation through existing gate flow with no active implementations', () => {
327
+ setupProfileMock();
328
+
329
+ // Default mock: RuleHost.evaluate returns undefined (already set in beforeEach)
330
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
331
+
332
+ // Should pass all gates — no block
333
+ expect(result).toBeUndefined();
334
+ });
335
+
336
+ it('should block with GFI even when Rule Host would allow', () => {
337
+ // High GFI triggers GFI block
338
+ vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 85 } as any);
339
+ setupProfileMock();
340
+
341
+ // Even if RuleHost would allow, GFI blocks first
342
+ const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
343
+
344
+ expect(result?.block).toBe(true);
345
+ expect(result?.blockReason).toContain('GFI');
346
+ });
347
+
348
+ it('should allow edit when oldText matches (full pipeline with Rule Host)', () => {
349
+ const fileContent = 'const x = 1;\n';
350
+ const editEvent = {
351
+ toolName: 'edit',
352
+ params: {
353
+ file_path: 'src/example.ts',
354
+ oldText: 'const x = 1;',
355
+ newText: 'const x = 2;',
356
+ },
357
+ };
358
+
359
+ setupProfileMock();
360
+
361
+ vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
362
+ if (typeof p === 'string' && p.includes('PROFILE.json')) {
363
+ return JSON.stringify({
364
+ risk_paths: [],
365
+ progressive_gate: { enabled: true },
366
+ edit_verification: { enabled: true },
367
+ });
368
+ }
369
+ if (typeof p === 'string' && p.includes('example.ts')) {
370
+ return fileContent;
371
+ }
372
+ return '';
373
+ });
374
+ vi.mocked(fs.statSync).mockReturnValue({ size: 1000 } as any);
375
+ vi.mocked(fs.existsSync).mockImplementation((p: any) => {
376
+ if (typeof p === 'string' && p.includes('PROFILE.json')) return true;
377
+ if (typeof p === 'string' && p.includes('example.ts')) return true;
378
+ return false;
379
+ });
380
+
381
+ const result = handleBeforeToolCall(editEvent as any, { workspaceDir, sessionId } as any);
382
+
383
+ expect(result).toBeUndefined();
384
+ });
385
+ });