principles-disciple 1.16.0 → 1.18.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 (132) hide show
  1. package/README.md +13 -5
  2. package/openclaw.plugin.json +4 -4
  3. package/package.json +1 -1
  4. package/src/commands/archive-impl.ts +3 -3
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +3 -3
  7. package/src/commands/disable-impl.ts +1 -1
  8. package/src/commands/evolution-status.ts +2 -2
  9. package/src/commands/focus.ts +2 -2
  10. package/src/commands/nocturnal-train.ts +6 -6
  11. package/src/commands/pain.ts +4 -4
  12. package/src/commands/pd-reflect.ts +87 -0
  13. package/src/commands/rollback-impl.ts +4 -4
  14. package/src/commands/rollback.ts +2 -2
  15. package/src/commands/samples.ts +2 -2
  16. package/src/commands/workflow-debug.ts +1 -1
  17. package/src/config/errors.ts +1 -1
  18. package/src/core/adaptive-thresholds.ts +1 -1
  19. package/src/core/code-implementation-storage.ts +2 -2
  20. package/src/core/config.ts +1 -1
  21. package/src/core/diagnostician-task-store.ts +2 -2
  22. package/src/core/empathy-keyword-matcher.ts +3 -3
  23. package/src/core/event-log.ts +5 -5
  24. package/src/core/evolution-engine.ts +4 -4
  25. package/src/core/evolution-logger.ts +1 -1
  26. package/src/core/evolution-reducer.ts +3 -3
  27. package/src/core/evolution-types.ts +5 -5
  28. package/src/core/external-training-contract.ts +1 -1
  29. package/src/core/focus-history.ts +14 -14
  30. package/src/core/hygiene/tracker.ts +1 -1
  31. package/src/core/init.ts +2 -2
  32. package/src/core/model-deployment-registry.ts +2 -2
  33. package/src/core/model-training-registry.ts +2 -2
  34. package/src/core/nocturnal-arbiter.ts +1 -1
  35. package/src/core/nocturnal-artificer.ts +2 -2
  36. package/src/core/nocturnal-candidate-scoring.ts +2 -2
  37. package/src/core/nocturnal-compliance.ts +4 -3
  38. package/src/core/nocturnal-dataset.ts +3 -3
  39. package/src/core/nocturnal-export.ts +4 -4
  40. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  41. package/src/core/nocturnal-snapshot-contract.ts +112 -0
  42. package/src/core/nocturnal-trajectory-extractor.ts +7 -5
  43. package/src/core/nocturnal-trinity.ts +480 -158
  44. package/src/core/pain-context-extractor.ts +3 -3
  45. package/src/core/pain.ts +124 -11
  46. package/src/core/path-resolver.ts +4 -4
  47. package/src/core/pd-task-reconciler.ts +10 -10
  48. package/src/core/pd-task-service.ts +1 -1
  49. package/src/core/pd-task-store.ts +1 -1
  50. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  51. package/src/core/principle-training-state.ts +2 -2
  52. package/src/core/principle-tree-ledger.ts +7 -7
  53. package/src/core/promotion-gate.ts +9 -9
  54. package/src/core/replay-engine.ts +12 -12
  55. package/src/core/risk-calculator.ts +1 -1
  56. package/src/core/rule-host-types.ts +2 -2
  57. package/src/core/rule-host.ts +5 -5
  58. package/src/core/schema/db-types.ts +1 -1
  59. package/src/core/schema/schema-definitions.ts +1 -1
  60. package/src/core/session-tracker.ts +96 -4
  61. package/src/core/shadow-observation-registry.ts +3 -3
  62. package/src/core/system-logger.ts +2 -2
  63. package/src/core/thinking-os-parser.ts +1 -1
  64. package/src/core/training-program.ts +2 -2
  65. package/src/core/trajectory.ts +8 -8
  66. package/src/core/workspace-context.ts +2 -2
  67. package/src/core/workspace-dir-service.ts +85 -0
  68. package/src/core/workspace-dir-validation.ts +30 -107
  69. package/src/hooks/bash-risk.ts +3 -3
  70. package/src/hooks/edit-verification.ts +4 -4
  71. package/src/hooks/gate-block-helper.ts +4 -4
  72. package/src/hooks/gate.ts +10 -10
  73. package/src/hooks/gfi-gate.ts +7 -7
  74. package/src/hooks/lifecycle.ts +2 -2
  75. package/src/hooks/llm.ts +1 -1
  76. package/src/hooks/pain.ts +25 -5
  77. package/src/hooks/progressive-trust-gate.ts +7 -7
  78. package/src/hooks/prompt.ts +24 -5
  79. package/src/hooks/subagent.ts +2 -2
  80. package/src/hooks/thinking-checkpoint.ts +2 -2
  81. package/src/hooks/trajectory-collector.ts +1 -1
  82. package/src/http/principles-console-route.ts +14 -6
  83. package/src/i18n/commands.ts +4 -0
  84. package/src/index.ts +181 -185
  85. package/src/service/central-health-service.ts +1 -1
  86. package/src/service/central-overview-service.ts +3 -3
  87. package/src/service/evolution-query-service.ts +1 -1
  88. package/src/service/evolution-worker.ts +221 -109
  89. package/src/service/health-query-service.ts +27 -17
  90. package/src/service/monitoring-query-service.ts +3 -3
  91. package/src/service/nocturnal-runtime.ts +4 -4
  92. package/src/service/nocturnal-service.ts +40 -23
  93. package/src/service/nocturnal-target-selector.ts +11 -4
  94. package/src/service/runtime-summary-service.ts +1 -1
  95. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +1 -1
  96. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +3 -3
  97. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -13
  98. package/src/service/subagent-workflow/runtime-direct-driver.ts +10 -6
  99. package/src/service/subagent-workflow/types.ts +4 -4
  100. package/src/service/subagent-workflow/workflow-manager-base.ts +5 -5
  101. package/src/service/subagent-workflow/workflow-store.ts +2 -2
  102. package/src/tools/critique-prompt.ts +2 -3
  103. package/src/tools/deep-reflect.ts +17 -16
  104. package/src/tools/model-index.ts +1 -1
  105. package/src/utils/file-lock.ts +1 -1
  106. package/src/utils/io.ts +7 -2
  107. package/src/utils/nlp.ts +1 -1
  108. package/src/utils/plugin-logger.ts +2 -2
  109. package/src/utils/retry.ts +3 -2
  110. package/src/utils/subagent-probe.ts +20 -33
  111. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +8 -7
  112. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/nocturnal-trinity-quality-enhancement.json +111 -0
  113. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/task-specs.mjs +1 -1
  114. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +1 -1
  115. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +8 -7
  116. package/templates/pain_settings.json +1 -1
  117. package/tests/build-artifacts.test.ts +4 -58
  118. package/tests/commands/pd-reflect.test.ts +49 -0
  119. package/tests/core/nocturnal-snapshot-contract.test.ts +70 -0
  120. package/tests/core/pain-auto-repair.test.ts +96 -0
  121. package/tests/core/pain-integration.test.ts +483 -0
  122. package/tests/core/pain.test.ts +5 -4
  123. package/tests/core/workspace-dir-service.test.ts +68 -0
  124. package/tests/core/workspace-dir-validation.test.ts +56 -192
  125. package/tests/hooks/pain.test.ts +20 -0
  126. package/tests/http/principles-console-route.test.ts +42 -20
  127. package/tests/integration/empathy-workflow-integration.test.ts +1 -2
  128. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +9 -17
  129. package/tests/service/empathy-observer-workflow-manager.test.ts +1 -2
  130. package/tests/service/evolution-worker.nocturnal.test.ts +118 -109
  131. package/tests/service/nocturnal-runtime-hardening.test.ts +33 -0
  132. package/tests/utils/subagent-probe.test.ts +32 -0
@@ -107,13 +107,13 @@ describe('Pain Detection Module', () => {
107
107
  const missing = validatePainFlag({
108
108
  source: 'tool_failure',
109
109
  score: '70',
110
- // missing time, reason, session_id, agent_id
110
+ // missing time, reason (session_id/agent_id are optional)
111
111
  });
112
112
 
113
113
  expect(missing).toContain('time');
114
114
  expect(missing).toContain('reason');
115
- expect(missing).toContain('session_id');
116
- expect(missing).toContain('agent_id');
115
+ expect(missing).not.toContain('session_id');
116
+ expect(missing).not.toContain('agent_id');
117
117
  });
118
118
 
119
119
  it('should report empty string fields as missing', () => {
@@ -126,7 +126,8 @@ describe('Pain Detection Module', () => {
126
126
  agent_id: 'main',
127
127
  });
128
128
 
129
- expect(missing).toContain('session_id');
129
+ // session_id/agent_id are optional — empty values are acceptable
130
+ expect(missing).toEqual([]);
130
131
  });
131
132
  });
132
133
  });
@@ -0,0 +1,68 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as os from 'os';
3
+ import { resolveRequiredWorkspaceDir, resolveWorkspaceDir } from '../../src/core/workspace-dir-service.js';
4
+
5
+ const homeDir = os.homedir();
6
+
7
+ describe('workspace-dir-service', () => {
8
+ const logger = {
9
+ error: vi.fn(),
10
+ warn: vi.fn(),
11
+ info: vi.fn(),
12
+ debug: vi.fn(),
13
+ };
14
+
15
+ const api = {
16
+ runtime: {
17
+ agent: {
18
+ resolveAgentWorkspaceDir: vi.fn(),
19
+ },
20
+ },
21
+ config: {},
22
+ logger,
23
+ };
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue('/resolved/workspace');
28
+ });
29
+
30
+ it('accepts valid ctx.workspaceDir directly', () => {
31
+ const resolved = resolveRequiredWorkspaceDir(api as any, { workspaceDir: '/active/workspace', agentId: 'main' }, { source: 'test' });
32
+ expect(resolved).toBe('/active/workspace');
33
+ expect(api.runtime.agent.resolveAgentWorkspaceDir).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('rejects home directory and resolves from agent id when available', () => {
37
+ const resolved = resolveWorkspaceDir(api as any, { workspaceDir: homeDir, agentId: 'worker-1' }, { source: 'test' });
38
+ expect(resolved).toBe('/resolved/workspace');
39
+ expect(api.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(api.config, 'worker-1');
40
+ });
41
+
42
+ it('throws in required mode when no valid workspace can be resolved', () => {
43
+ api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
44
+ throw new Error('no agent workspace');
45
+ });
46
+
47
+ expect(() => resolveRequiredWorkspaceDir(api as any, { workspaceDir: homeDir }, { source: 'command' })).toThrow(
48
+ /unable to resolve a valid workspace directory/i,
49
+ );
50
+ expect(logger.error).toHaveBeenCalled();
51
+ });
52
+
53
+ it('returns undefined in optional mode when no valid workspace can be resolved', () => {
54
+ api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
55
+ throw new Error('no agent workspace');
56
+ });
57
+
58
+ const resolved = resolveWorkspaceDir(api as any, { workspaceDir: homeDir }, { source: 'hook' });
59
+ expect(resolved).toBeUndefined();
60
+ expect(logger.warn).toHaveBeenCalled();
61
+ });
62
+
63
+ it('uses explicit fallbackAgentId when caller provides one', () => {
64
+ const resolved = resolveRequiredWorkspaceDir(api as any, {}, { source: 'startup', fallbackAgentId: 'main' });
65
+ expect(resolved).toBe('/resolved/workspace');
66
+ expect(api.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(api.config, 'main');
67
+ });
68
+ });
@@ -1,68 +1,26 @@
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';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
2
  import * as os from 'os';
10
3
  import { validateWorkspaceDir, resolveValidWorkspaceDir, logWorkspaceDirHealth } from '../../src/core/workspace-dir-validation.js';
11
4
 
12
5
  const homeDir = os.homedir();
13
6
 
14
7
  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');
8
+ it('rejects undefined or null values', () => {
9
+ expect(validateWorkspaceDir(undefined)).toBe('workspaceDir is undefined/null');
10
+ expect(validateWorkspaceDir(null as unknown as string)).toBe('workspaceDir is undefined/null');
45
11
  });
46
12
 
47
- it('should return null (valid) for a proper workspace path', () => {
48
- const result = validateWorkspaceDir('/home/user/projects/my-workspace');
49
- expect(result).toBeNull();
13
+ it('rejects home directory and root-like paths', () => {
14
+ expect(validateWorkspaceDir(homeDir)).toContain('home directory');
15
+ expect(validateWorkspaceDir(`${homeDir}/`)).toContain('home directory');
16
+ expect(validateWorkspaceDir('/')).toContain('root or empty');
17
+ expect(validateWorkspaceDir('')).toContain('undefined/null');
50
18
  });
51
19
 
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();
20
+ it('accepts normal workspace paths', () => {
21
+ expect(validateWorkspaceDir('/home/user/projects/workspace-main')).toBeNull();
22
+ expect(validateWorkspaceDir('/tmp/test-workspace')).toBeNull();
23
+ expect(validateWorkspaceDir('C:\\Users\\test\\workspace')).toBeNull();
66
24
  });
67
25
  });
68
26
 
@@ -81,108 +39,65 @@ describe('resolveValidWorkspaceDir', () => {
81
39
  },
82
40
  },
83
41
  config: {},
84
- resolvePath: vi.fn(),
85
42
  logger: mockLogger,
86
43
  };
87
44
 
88
45
  beforeEach(() => {
89
46
  vi.clearAllMocks();
90
- mockApi.resolvePath.mockReturnValue('/default/workspace');
91
47
  mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue('/resolved/from/agent');
92
48
  });
93
49
 
94
- afterEach(() => {
95
- vi.resetAllMocks();
96
- });
50
+ it('returns ctx.workspaceDir when valid', () => {
51
+ const result = resolveValidWorkspaceDir(
52
+ { workspaceDir: '/valid/workspace', agentId: 'main' },
53
+ mockApi as any,
54
+ { source: 'test' },
55
+ );
97
56
 
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
57
  expect(result).toBe('/valid/workspace');
103
- expect(mockLogger.warn).not.toHaveBeenCalled();
58
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).not.toHaveBeenCalled();
104
59
  });
105
60
 
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
- });
61
+ it('falls back to agent resolution when ctx.workspaceDir is invalid', () => {
62
+ const result = resolveValidWorkspaceDir(
63
+ { workspaceDir: homeDir, agentId: 'main' },
64
+ mockApi as any,
65
+ { source: 'test' },
66
+ );
114
67
 
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
68
  expect(result).toBe('/resolved/from/agent');
69
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(mockApi.config, 'main');
121
70
  });
122
71
 
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
- });
72
+ it('returns undefined when no valid workspace can be resolved', () => {
73
+ mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
130
74
 
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
- });
75
+ const result = resolveValidWorkspaceDir(
76
+ { workspaceDir: undefined, agentId: 'main' },
77
+ mockApi as any,
78
+ { source: 'test' },
79
+ );
143
80
 
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'));
81
+ expect(result).toBeUndefined();
82
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('unable to resolve a valid workspace directory'));
153
83
  });
154
84
 
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
- });
85
+ it('supports explicit fallbackAgentId', () => {
86
+ mockApi.runtime.agent.resolveAgentWorkspaceDir
87
+ .mockImplementationOnce(() => {
88
+ throw new Error('agent missing');
89
+ })
90
+ .mockReturnValueOnce('/resolved/from/fallback');
164
91
 
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
- });
92
+ const result = resolveValidWorkspaceDir(
93
+ { workspaceDir: undefined, agentId: 'worker-1' },
94
+ mockApi as any,
95
+ { source: 'test', fallbackAgentId: 'main' },
96
+ );
178
97
 
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:'));
98
+ expect(result).toBe('/resolved/from/fallback');
99
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenNthCalledWith(1, mockApi.config, 'worker-1');
100
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenNthCalledWith(2, mockApi.config, 'main');
186
101
  });
187
102
  });
188
103
 
@@ -201,7 +116,6 @@ describe('logWorkspaceDirHealth', () => {
201
116
  },
202
117
  },
203
118
  config: {},
204
- resolvePath: vi.fn().mockReturnValue('/valid/workspace'),
205
119
  logger: mockLogger,
206
120
  };
207
121
 
@@ -209,64 +123,14 @@ describe('logWorkspaceDirHealth', () => {
209
123
  vi.clearAllMocks();
210
124
  });
211
125
 
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('✓'));
126
+ it('logs info when workspace is valid', () => {
127
+ logWorkspaceDirHealth({ workspaceDir: '/valid/workspace' }, 'startup', mockApi as any);
128
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('workspaceDir="/valid/workspace" OK'));
217
129
  });
218
130
 
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
131
+ it('logs error when workspace remains unresolved', () => {
266
132
  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('.');
133
+ logWorkspaceDirHealth({ workspaceDir: undefined }, 'startup', mockApi as any);
134
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('workspaceDir="undefined"'));
271
135
  });
272
136
  });
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { handleAfterToolCall } from '../../src/hooks/pain.js';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
+ import * as os from 'os';
5
6
  import * as ioUtils from '../../src/utils/io.js';
6
7
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
7
8
  import { EventLogService } from '../../src/core/event-log.js';
@@ -82,6 +83,25 @@ describe('Post-Write Checks & Pain Hook', () => {
82
83
  expect(mockEmitSync).not.toHaveBeenCalled();
83
84
  });
84
85
 
86
+ it('skips processing when no valid workspace can be resolved', () => {
87
+ const mockCtx = { workspaceDir: undefined, agentId: 'main', sessionId: 's-invalid' };
88
+ const mockEvent = { toolName: 'write', params: {}, result: { exitCode: 0 }, error: undefined };
89
+ const mockApi = {
90
+ runtime: {
91
+ agent: {
92
+ resolveAgentWorkspaceDir: vi.fn().mockReturnValue(os.homedir()),
93
+ },
94
+ },
95
+ config: {},
96
+ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
97
+ };
98
+
99
+ handleAfterToolCall(mockEvent as any, mockCtx as any, mockApi as any);
100
+
101
+ expect(WorkspaceContext.fromHookContext).not.toHaveBeenCalled();
102
+ expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(mockApi.config, 'main');
103
+ });
104
+
85
105
  it('should capture pain on tool error with correct source', () => {
86
106
  const mockCtx = { workspaceDir, sessionId: 's1', api: { logger: {} } };
87
107
  const mockEvent = {
@@ -48,6 +48,17 @@ function createRequest(method: string, url: string, body?: string, headers?: Rec
48
48
  }
49
49
 
50
50
  describe('principles-console-route', () => {
51
+ const createApi = () => ({
52
+ rootDir: '/plugin',
53
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
54
+ runtime: {
55
+ agent: {
56
+ resolveAgentWorkspaceDir: vi.fn(() => '/workspace'),
57
+ },
58
+ },
59
+ config: {},
60
+ });
61
+
51
62
  it('serves overview JSON from the plugin API route', async () => {
52
63
  vi.mocked(ControlUiQueryService).mockImplementation(function MockControlUiQueryService() {
53
64
  return {
@@ -56,11 +67,8 @@ describe('principles-console-route', () => {
56
67
  } as any;
57
68
  } as any);
58
69
 
59
- const route = createPrinciplesConsoleRoute({
60
- rootDir: '/plugin',
61
- resolvePath: vi.fn(() => '/workspace'),
62
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
63
- } as any);
70
+ const api = createApi();
71
+ const route = createPrinciplesConsoleRoute(api as any);
64
72
 
65
73
  const response = new MockResponse() as unknown as ServerResponse;
66
74
  const handled = await route.handler(
@@ -71,14 +79,11 @@ describe('principles-console-route', () => {
71
79
  expect(handled).toBe(true);
72
80
  expect((response as any).statusCode).toBe(200);
73
81
  expect((response as any).body).toContain('"workspaceDir": "/workspace"');
82
+ expect(api.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalled();
74
83
  });
75
84
 
76
85
  it('rejects unsupported asset methods with 405', async () => {
77
- const route = createPrinciplesConsoleRoute({
78
- rootDir: '/plugin',
79
- resolvePath: vi.fn(() => '/workspace'),
80
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
81
- } as any);
86
+ const route = createPrinciplesConsoleRoute(createApi() as any);
82
87
 
83
88
  const response = new MockResponse() as unknown as ServerResponse;
84
89
  const handled = await route.handler(
@@ -97,11 +102,7 @@ describe('principles-console-route', () => {
97
102
  } as any;
98
103
  } as any);
99
104
 
100
- const route = createPrinciplesConsoleRoute({
101
- rootDir: '/plugin',
102
- resolvePath: vi.fn(() => '/workspace'),
103
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
104
- } as any);
105
+ const route = createPrinciplesConsoleRoute(createApi() as any);
105
106
 
106
107
  const response = new MockResponse() as unknown as ServerResponse;
107
108
  const handled = await route.handler(
@@ -122,11 +123,7 @@ describe('principles-console-route', () => {
122
123
  } as any;
123
124
  } as any);
124
125
 
125
- const route = createPrinciplesConsoleRoute({
126
- rootDir: '/plugin',
127
- resolvePath: vi.fn(() => '/workspace'),
128
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
129
- } as any);
126
+ const route = createPrinciplesConsoleRoute(createApi() as any);
130
127
 
131
128
  const response = new MockResponse() as unknown as ServerResponse;
132
129
  const handled = await route.handler(
@@ -137,4 +134,29 @@ describe('principles-console-route', () => {
137
134
  expect(handled).toBe(true);
138
135
  expect((response as any).statusCode).toBe(404);
139
136
  });
137
+
138
+ it('fails fast when workspace resolution is unavailable', async () => {
139
+ vi.mocked(ControlUiQueryService).mockImplementation(function MockControlUiQueryService() {
140
+ return {
141
+ getOverview: () => ({ workspaceDir: '/workspace', generatedAt: 'now', dataFreshness: null }),
142
+ dispose: vi.fn(),
143
+ } as any;
144
+ } as any);
145
+
146
+ const api = createApi();
147
+ api.runtime.agent.resolveAgentWorkspaceDir = vi.fn(() => {
148
+ throw new Error('workspace unavailable');
149
+ });
150
+ const route = createPrinciplesConsoleRoute(api as any);
151
+
152
+ const response = new MockResponse() as unknown as ServerResponse;
153
+ const handled = await route.handler(
154
+ createRequest('GET', '/plugins/principles/api/overview'),
155
+ response,
156
+ );
157
+
158
+ expect(handled).toBe(true);
159
+ expect((response as any).statusCode).toBe(500);
160
+ expect((response as any).body).toContain('unable to resolve a valid workspace directory');
161
+ });
140
162
  });
@@ -20,8 +20,7 @@ import { handleBeforePromptBuild } from '../../src/hooks/prompt.js';
20
20
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
21
21
 
22
22
  /**
23
- * Helper to create a mock function that properly reports as AsyncFunction.
24
- * This is required because isSubagentRuntimeAvailable() checks constructor.name === 'AsyncFunction'.
23
+ * Helper to create a mock async function for workflow-manager tests.
25
24
  */
26
25
  function mockAsyncFn<T extends (...args: any[]) => Promise<any>>(impl: (...args: any[]) => any) {
27
26
  const fn = vi.fn(impl) as unknown as T;
@@ -79,7 +79,7 @@ describe('E2E: Tool Hooks workspaceDir Resolution', () => {
79
79
  expect(mockApi.runtime.agent.resolveAgentWorkspaceDir).toHaveBeenCalledWith(mockApi.config, 'test-agent');
80
80
  });
81
81
 
82
- it('should fallback to resolvePath when agentId is also undefined', async () => {
82
+ it('should refuse to guess a workspace when agentId is also undefined', async () => {
83
83
  const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-validation.js');
84
84
 
85
85
  const mockApi = createMockApi(testWorkspaceDir);
@@ -90,8 +90,8 @@ describe('E2E: Tool Hooks workspaceDir Resolution', () => {
90
90
 
91
91
  const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'after_tool_call' });
92
92
 
93
- expect(result).toBe(testWorkspaceDir);
94
- expect(mockApi.resolvePath).toHaveBeenCalledWith('.');
93
+ expect(result).toBeUndefined();
94
+ expect(mockApi.resolvePath).not.toHaveBeenCalled();
95
95
  });
96
96
  });
97
97
 
@@ -129,26 +129,18 @@ describe('E2E: Tool Hooks workspaceDir Resolution', () => {
129
129
  });
130
130
  });
131
131
 
132
- describe('Scenario 4: Warning is logged when workspaceDir resolution fails', () => {
133
- it('should warn when falling back to resolvePath with invalid result', async () => {
132
+ describe('Scenario 4: Invalid workspace candidates are rejected', () => {
133
+ it('should return undefined when all workspace resolution candidates are invalid', async () => {
134
134
  const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-validation.js');
135
135
 
136
- const mockApi = createMockApi(os.homedir()); // resolvePath returns home dir
137
- mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(os.homedir()); // agentId also returns home
138
-
139
- const warningMessages: string[] = [];
140
- const onWarning = (msg: string) => warningMessages.push(msg);
136
+ const mockApi = createMockApi(os.homedir());
137
+ mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(os.homedir());
141
138
 
142
139
  const ctx = { workspaceDir: undefined, agentId: 'test-agent' };
143
140
 
144
- const result = resolveValidWorkspaceDir(ctx, mockApi as any, {
145
- source: 'test',
146
- onWarning,
147
- });
141
+ const result = resolveValidWorkspaceDir(ctx, mockApi as any, { source: 'test' });
148
142
 
149
- // Should have warnings about invalid paths
150
- expect(warningMessages.length).toBeGreaterThan(0);
151
- expect(warningMessages.some(m => m.includes('FINAL FALLBACK') || m.includes('invalid'))).toBe(true);
143
+ expect(result).toBeUndefined();
152
144
  });
153
145
  });
154
146
  });
@@ -6,8 +6,7 @@ import { EmpathyObserverWorkflowManager } from '../../src/service/subagent-workf
6
6
  import type { SubagentWorkflowSpec } from '../../src/service/subagent-workflow/types.js';
7
7
 
8
8
  /**
9
- * Helper to create a mock function that properly reports as AsyncFunction.
10
- * This is required because isSubagentRuntimeAvailable() checks constructor.name === 'AsyncFunction'.
9
+ * Helper to create a mock async function for workflow-manager tests.
11
10
  */
12
11
  function mockAsyncFn<T extends (...args: any[]) => Promise<any>>(impl: (...args: any[]) => any) {
13
12
  const fn = vi.fn(impl) as unknown as T;