principles-disciple 1.83.1 → 1.84.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.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.83.1",
5
+ "version": "1.84.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.83.1",
3
+ "version": "1.84.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
package/src/index.ts CHANGED
@@ -58,7 +58,7 @@ import { migrateDirectoryStructure } from './core/migration.js';
58
58
  import { migrateStaleWorkspaceGuidance } from './core/workspace-guidance-migrator.js';
59
59
  import { SystemLogger } from './core/system-logger.js';
60
60
  import { PathResolver } from './core/path-resolver.js';
61
- import { resolveCommandWorkspaceDir, resolveToolHookWorkspaceDirSafe } from './utils/workspace-resolver.js';
61
+ import { resolveCommandWorkspaceDir, resolveToolHookWorkspaceDirSafe, resolveHookWorkspaceDir } from './utils/workspace-resolver.js';
62
62
  import { computeRuntimeShadowTaskFingerprint, PD_LOCAL_PROFILES } from './utils/shadow-fingerprint.js';
63
63
  import type { WorkerProfile } from './core/model-deployment-registry.js';
64
64
  import { validateWorkspaceDir } from './core/workspace-dir-validation.js';
@@ -68,10 +68,6 @@ import { checkSurfaceGuard, guardHook, guardService } from './core/surface-guard
68
68
  // Track started workspaces — one-time init + evolution worker per workspace
69
69
  const startedWorkspaces = new Set<string>();
70
70
 
71
- const HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION =
72
- 'verify gateway plugin activation and hook workspace binding; ' +
73
- 'migrate live hook workspace resolution to PD-owned canonical configuration before relying on config-based recovery';
74
-
75
71
  // Map from childSessionKey → shadowObservationId
76
72
  // Used to complete shadow observations when subagent ends
77
73
  const pendingShadowObservations = new Map<string, string>();
@@ -235,17 +231,20 @@ const plugin = {
235
231
  api.on(
236
232
  'before_prompt_build',
237
233
  guardHook('hook:before_prompt_build', api.logger, async (event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext): Promise<PluginHookBeforePromptBuildResult | void> => {
238
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_prompt_build');
239
- if (!workspaceDir) {
234
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_prompt_build');
235
+ if (!wsResult.ok) {
240
236
  api.logger.error(
241
237
  `[PD:before_prompt_build] workspaceDir resolution failed. ` +
242
- `agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'} ` +
243
- `sessionKey=${ctx.sessionKey ?? '(missing)'}. ` +
238
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
244
239
  `Hook skipped — no mutation will occur. ` +
245
- `NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
240
+ `NextAction: ${wsResult.nextAction}`,
246
241
  );
247
242
  return;
248
243
  }
244
+ const workspaceDir = wsResult.workspaceDir;
245
+ if (wsResult.consistencyWarning) {
246
+ api.logger.warn(`[PD:before_prompt_build] ${wsResult.consistencyWarning}`);
247
+ }
249
248
  try {
250
249
  if (!startedWorkspaces.has(workspaceDir)) {
251
250
  startedWorkspaces.add(workspaceDir);
@@ -313,17 +312,20 @@ const plugin = {
313
312
  api.on(
314
313
  'before_tool_call',
315
314
  guardHook('hook:before_tool_call', api.logger, (event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): PluginHookBeforeToolCallResult | void => {
316
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_tool_call');
317
- if (!workspaceDir) {
315
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_tool_call');
316
+ if (!wsResult.ok) {
318
317
  api.logger.error(
319
318
  `[PD:before_tool_call] workspaceDir resolution failed. ` +
320
- `agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'} ` +
321
- `sessionKey=${ctx.sessionKey ?? '(missing)'}. ` +
319
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
322
320
  `Hook skipped — security gate bypassed. ` +
323
- `NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
321
+ `NextAction: ${wsResult.nextAction}`,
324
322
  );
325
323
  return;
326
324
  }
325
+ const workspaceDir = wsResult.workspaceDir;
326
+ if (wsResult.consistencyWarning) {
327
+ api.logger.warn(`[PD:before_tool_call] ${wsResult.consistencyWarning}`);
328
+ }
327
329
  try {
328
330
  const pluginConfig = api.pluginConfig ?? {};
329
331
  const {logger} = api;
@@ -348,17 +350,20 @@ const plugin = {
348
350
  api.on(
349
351
  'after_tool_call',
350
352
  guardHook('hook:after_tool_call', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
351
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'after_tool_call');
352
- if (!workspaceDir) {
353
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'after_tool_call');
354
+ if (!wsResult.ok) {
353
355
  api.logger.error(
354
356
  `[PD:after_tool_call] workspaceDir resolution failed. ` +
355
- `agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'} ` +
356
- `sessionKey=${ctx.sessionKey ?? '(missing)'}. ` +
357
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
357
358
  `Hook skipped — pain detection bypassed. ` +
358
- `NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
359
+ `NextAction: ${wsResult.nextAction}`,
359
360
  );
360
361
  return;
361
362
  }
363
+ const workspaceDir = wsResult.workspaceDir;
364
+ if (wsResult.consistencyWarning) {
365
+ api.logger.warn(`[PD:after_tool_call] ${wsResult.consistencyWarning}`);
366
+ }
362
367
  try {
363
368
  const pluginConfig = api.pluginConfig ?? {};
364
369
  // Pass api separately to handleAfterToolCall to maintain type safety
@@ -381,17 +386,21 @@ const plugin = {
381
386
  api.on(
382
387
  'llm_output',
383
388
  guardHook('hook:llm_output', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
384
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'llm_output');
385
- if (!workspaceDir) {
389
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'llm_output');
390
+ if (!wsResult.ok) {
386
391
  api.logger.error(
387
392
  `[PD:llm_output] workspaceDir resolution failed. ` +
388
- `agentId=${ctx.agentId ?? '(missing)'} ` +
393
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} ` +
389
394
  `sessionId=${ctx.sessionId ?? '(missing)'}. ` +
390
395
  `Hook skipped — LLM analysis bypassed. ` +
391
- `NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
396
+ `NextAction: ${wsResult.nextAction}`,
392
397
  );
393
398
  return;
394
399
  }
400
+ const workspaceDir = wsResult.workspaceDir;
401
+ if (wsResult.consistencyWarning) {
402
+ api.logger.warn(`[PD:llm_output] ${wsResult.consistencyWarning}`);
403
+ }
395
404
  try {
396
405
  handleLlmOutput(event, { ...ctx, workspaceDir });
397
406
 
@@ -525,42 +534,51 @@ const plugin = {
525
534
 
526
535
  // ── Hook: Lifecycle ──
527
536
  api.on('before_reset', guardHook('hook:before_reset', api.logger, (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
528
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_reset');
529
- if (!workspaceDir) {
537
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_reset');
538
+ if (!wsResult.ok) {
530
539
  api.logger.error(
531
540
  `[PD:before_reset] workspaceDir resolution failed. ` +
532
- `agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
533
- `Hook skipped. NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
541
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
542
+ `Hook skipped. NextAction: ${wsResult.nextAction}`,
534
543
  );
535
544
  return;
536
545
  }
537
- return handleBeforeReset(event, { ...ctx, workspaceDir });
546
+ if (wsResult.consistencyWarning) {
547
+ api.logger.warn(`[PD:before_reset] ${wsResult.consistencyWarning}`);
548
+ }
549
+ return handleBeforeReset(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
538
550
  }));
539
551
 
540
552
  api.on('before_compaction', guardHook('hook:before_compaction', api.logger, (event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext) => {
541
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'before_compaction');
542
- if (!workspaceDir) {
553
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_compaction');
554
+ if (!wsResult.ok) {
543
555
  api.logger.error(
544
556
  `[PD:before_compaction] workspaceDir resolution failed. ` +
545
- `agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
546
- `Hook skipped. NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
557
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
558
+ `Hook skipped. NextAction: ${wsResult.nextAction}`,
547
559
  );
548
560
  return;
549
561
  }
550
- return handleBeforeCompaction(event, { ...ctx, workspaceDir });
562
+ if (wsResult.consistencyWarning) {
563
+ api.logger.warn(`[PD:before_compaction] ${wsResult.consistencyWarning}`);
564
+ }
565
+ return handleBeforeCompaction(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
551
566
  }));
552
567
 
553
568
  api.on('after_compaction', guardHook('hook:after_compaction', api.logger, (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
554
- const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'after_compaction');
555
- if (!workspaceDir) {
569
+ const wsResult = resolveHookWorkspaceDir(ctx, api, 'after_compaction');
570
+ if (!wsResult.ok) {
556
571
  api.logger.error(
557
572
  `[PD:after_compaction] workspaceDir resolution failed. ` +
558
- `agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
559
- `Hook skipped. NextAction: ${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}`,
573
+ `reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
574
+ `Hook skipped. NextAction: ${wsResult.nextAction}`,
560
575
  );
561
576
  return;
562
577
  }
563
- return handleAfterCompaction(event, { ...ctx, workspaceDir });
578
+ if (wsResult.consistencyWarning) {
579
+ api.logger.warn(`[PD:after_compaction] ${wsResult.consistencyWarning}`);
580
+ }
581
+ return handleAfterCompaction(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
564
582
  }));
565
583
 
566
584
  // ── Service Registration (surface-guarded) ──
@@ -2,13 +2,19 @@
2
2
  * Workspace Directory Resolution Utilities
3
3
  *
4
4
  * Shared helpers for resolving workspace directories across commands and hooks.
5
+ *
6
+ * Hook resolution priority (PRI-259): PD canonical config → OpenClaw fallback.
7
+ * PD canonical sources: PD_WORKSPACE_DIR env → OPENCLAW_WORKSPACE env →
8
+ * principles-disciple.json → ~/.openclaw/workspace default.
9
+ * OpenClaw fallback: ctx.workspaceDir → api.runtime.agent.resolveAgentWorkspaceDir().
5
10
  */
6
11
 
7
12
  import type { OpenClawPluginApi, PluginCommandContext } from '../openclaw-sdk.js';
8
13
  import { validateWorkspaceDir, type WorkspaceResolutionContext } from '../core/workspace-dir-validation.js';
9
- import { resolveWorkspaceDir } from '../core/workspace-dir-service.js';
10
14
  import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
11
15
  import * as path from 'path';
16
+ import * as os from 'os';
17
+ import * as fs from 'fs';
12
18
 
13
19
  /**
14
20
  * Resolve workspace directory for command execution.
@@ -83,16 +89,258 @@ export function resolvePluginCommandWorkspaceDir(
83
89
  );
84
90
  }
85
91
 
92
+ // ── PD Canonical Workspace Config Resolution (PRI-259) ──────────────────
93
+
94
+ export type CanonicalWorkspaceSource = 'pd_env' | 'openclaw_env' | 'pd_config' | 'pd_default';
95
+
96
+ export interface CanonicalWorkspaceResult {
97
+ workspaceDir: string;
98
+ source: CanonicalWorkspaceSource;
99
+ }
100
+
101
+ const PD_CONFIG_FILENAME = 'principles-disciple.json';
102
+
103
+ function loadWorkspaceFromPdConfigFile(): string | null {
104
+ const candidates = [
105
+ path.join(os.homedir(), '.openclaw', PD_CONFIG_FILENAME),
106
+ path.join(os.homedir(), '.principles', PD_CONFIG_FILENAME),
107
+ path.join(process.cwd(), PD_CONFIG_FILENAME),
108
+ ];
109
+
110
+ for (const configPath of candidates) {
111
+ if (!fs.existsSync(configPath)) continue;
112
+ try {
113
+ const raw = fs.readFileSync(configPath, 'utf8');
114
+ const parsed: unknown = JSON.parse(raw);
115
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
116
+ if (Object.hasOwn(parsed, 'workspace')) {
117
+ const workspaceValue = (parsed as Record<string, unknown>)['workspace'];
118
+ if (typeof workspaceValue === 'string' && workspaceValue.trim()) {
119
+ return workspaceValue.trim();
120
+ }
121
+ }
122
+ }
123
+ } catch {
124
+ continue;
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export function resolveCanonicalWorkspaceDir(): CanonicalWorkspaceResult | null {
131
+ const pdEnv = process.env.PD_WORKSPACE_DIR;
132
+ if (pdEnv && pdEnv.trim()) {
133
+ const dir = path.resolve(pdEnv.trim());
134
+ if (!validateWorkspaceDir(dir)) {
135
+ return { workspaceDir: dir, source: 'pd_env' };
136
+ }
137
+ }
138
+
139
+ const ocEnv = process.env.OPENCLAW_WORKSPACE;
140
+ if (ocEnv && ocEnv.trim()) {
141
+ const dir = path.resolve(ocEnv.trim());
142
+ if (!validateWorkspaceDir(dir)) {
143
+ return { workspaceDir: dir, source: 'openclaw_env' };
144
+ }
145
+ }
146
+
147
+ const configWorkspace = loadWorkspaceFromPdConfigFile();
148
+ if (configWorkspace) {
149
+ const dir = path.resolve(configWorkspace);
150
+ if (!validateWorkspaceDir(dir)) {
151
+ return { workspaceDir: dir, source: 'pd_config' };
152
+ }
153
+ }
154
+
155
+ const defaultDir = path.join(os.homedir(), '.openclaw', 'workspace');
156
+ if (!validateWorkspaceDir(defaultDir)) {
157
+ return { workspaceDir: defaultDir, source: 'pd_default' };
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Resolve only PD explicit sources (env vars + config file), excluding pd_default.
165
+ * Used by hook resolution to ensure ctx.workspaceDir takes priority over the
166
+ * hardcoded default fallback.
167
+ */
168
+ function resolveExplicitPdSources(): CanonicalWorkspaceResult | null {
169
+ const pdEnv = process.env.PD_WORKSPACE_DIR;
170
+ if (pdEnv && pdEnv.trim()) {
171
+ const dir = path.resolve(pdEnv.trim());
172
+ if (!validateWorkspaceDir(dir)) {
173
+ return { workspaceDir: dir, source: 'pd_env' };
174
+ }
175
+ }
176
+
177
+ const ocEnv = process.env.OPENCLAW_WORKSPACE;
178
+ if (ocEnv && ocEnv.trim()) {
179
+ const dir = path.resolve(ocEnv.trim());
180
+ if (!validateWorkspaceDir(dir)) {
181
+ return { workspaceDir: dir, source: 'openclaw_env' };
182
+ }
183
+ }
184
+
185
+ const configWorkspace = loadWorkspaceFromPdConfigFile();
186
+ if (configWorkspace) {
187
+ const dir = path.resolve(configWorkspace);
188
+ if (!validateWorkspaceDir(dir)) {
189
+ return { workspaceDir: dir, source: 'pd_config' };
190
+ }
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ // ── Hook Workspace Resolution (PRI-259) ────────────────────────────────
197
+
198
+ export type HookWorkspaceSource = CanonicalWorkspaceSource | 'openclaw_context' | 'openclaw_api';
199
+
200
+ export interface HookWorkspaceResolutionSuccess {
201
+ ok: true;
202
+ workspaceDir: string;
203
+ source: HookWorkspaceSource;
204
+ consistencyWarning?: string;
205
+ }
206
+
207
+ export interface HookWorkspaceResolutionFailure {
208
+ ok: false;
209
+ reason: string;
210
+ nextAction: string;
211
+ message: string;
212
+ }
213
+
214
+ export type HookWorkspaceResolutionResult =
215
+ | HookWorkspaceResolutionSuccess
216
+ | HookWorkspaceResolutionFailure;
217
+
218
+ function tryResolveFromOpenClawApi(
219
+ api: OpenClawPluginApi,
220
+ agentId: string | undefined,
221
+ ): string | undefined {
222
+ try {
223
+ const resolved = api.runtime?.agent?.resolveAgentWorkspaceDir?.(api.config, agentId ?? 'main');
224
+ if (resolved && !validateWorkspaceDir(resolved)) {
225
+ return resolved;
226
+ }
227
+ } catch {
228
+ // Fall through
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ export interface HookWorkspaceResolutionOptions {
234
+ canonicalResolver?: () => CanonicalWorkspaceResult | null;
235
+ explicitPdResolver?: () => CanonicalWorkspaceResult | null;
236
+ }
237
+
238
+ export function resolveHookWorkspaceDir(
239
+ ctx: WorkspaceResolutionContext,
240
+ api: OpenClawPluginApi,
241
+ source: string,
242
+ options?: HookWorkspaceResolutionOptions,
243
+ ): HookWorkspaceResolutionResult {
244
+ // Priority 1: PD explicit sources (env vars + config file) — these are
245
+ // owner-declared and intentionally override the live session context.
246
+ const resolveExplicit = options?.explicitPdResolver ?? resolveExplicitPdSources;
247
+ const explicit = resolveExplicit();
248
+
249
+ if (explicit) {
250
+ let consistencyWarning: string | undefined;
251
+
252
+ if (ctx.workspaceDir) {
253
+ const normalizedCtx = path.resolve(ctx.workspaceDir);
254
+ const normalizedExplicit = path.resolve(explicit.workspaceDir);
255
+ if (normalizedCtx !== normalizedExplicit) {
256
+ consistencyWarning =
257
+ `PD explicit workspace (${explicit.source}: ${explicit.workspaceDir}) ` +
258
+ `differs from OpenClaw context (${ctx.workspaceDir}). Using PD explicit.`;
259
+ }
260
+ }
261
+
262
+ return {
263
+ ok: true,
264
+ workspaceDir: explicit.workspaceDir,
265
+ source: explicit.source,
266
+ consistencyWarning,
267
+ };
268
+ }
269
+
270
+ // Priority 2: OpenClaw live context — the real session workspace.
271
+ // This MUST take priority over pd_default (the hardcoded fallback).
272
+ if (ctx.workspaceDir) {
273
+ const issue = validateWorkspaceDir(ctx.workspaceDir);
274
+ if (!issue) {
275
+ return {
276
+ ok: true,
277
+ workspaceDir: ctx.workspaceDir,
278
+ source: 'openclaw_context',
279
+ };
280
+ }
281
+ }
282
+
283
+ // Priority 3: OpenClaw API resolution
284
+ const apiResolved = tryResolveFromOpenClawApi(api, ctx.agentId);
285
+ if (apiResolved) {
286
+ return {
287
+ ok: true,
288
+ workspaceDir: apiResolved,
289
+ source: 'openclaw_api',
290
+ };
291
+ }
292
+
293
+ // Priority 4: pd_default (hardcoded fallback) — only when nothing else works
294
+ const resolveCanonical = options?.canonicalResolver ?? resolveCanonicalWorkspaceDir;
295
+ const canonical = resolveCanonical();
296
+ if (canonical && canonical.source === 'pd_default') {
297
+ return {
298
+ ok: true,
299
+ workspaceDir: canonical.workspaceDir,
300
+ source: 'pd_default',
301
+ consistencyWarning:
302
+ 'Using hardcoded default workspace (~/.openclaw/workspace). ' +
303
+ 'Set PD_WORKSPACE_DIR or create ~/.openclaw/principles-disciple.json for stable resolution.',
304
+ };
305
+ }
306
+
307
+ return {
308
+ ok: false,
309
+ reason: 'workspace_dir_unresolvable',
310
+ nextAction:
311
+ 'Set PD_WORKSPACE_DIR environment variable, create ~/.openclaw/principles-disciple.json ' +
312
+ 'with a "workspace" field, or ensure OpenClaw provides workspaceDir in hook context.',
313
+ message:
314
+ `[PD:${source}] Cannot resolve workspace directory from any source. ` +
315
+ `PD explicit config (PD_WORKSPACE_DIR, principles-disciple.json) ` +
316
+ `and OpenClaw fallback (ctx.workspaceDir, api.resolveAgentWorkspaceDir, ~/.openclaw/workspace) all failed.`,
317
+ };
318
+ }
319
+
86
320
  /**
87
321
  * Resolve workspace directory for tool hook execution (safe version).
88
322
  * Returns undefined instead of throwing if resolution fails.
323
+ *
324
+ * PRI-259: Uses PD canonical config as primary source, OpenClaw as fallback.
89
325
  */
90
326
  export function resolveToolHookWorkspaceDirSafe(
91
327
  ctx: WorkspaceResolutionContext,
92
328
  api: OpenClawPluginApi,
93
329
  source: string,
330
+ options?: HookWorkspaceResolutionOptions,
94
331
  ): string | undefined {
95
- return resolveWorkspaceDir(api, ctx, { source });
332
+ const result = resolveHookWorkspaceDir(ctx, api, source, options);
333
+
334
+ if (!result.ok) {
335
+ api.logger.warn(result.message);
336
+ return undefined;
337
+ }
338
+
339
+ if (result.consistencyWarning) {
340
+ api.logger.warn(`[PD:${source}] ${result.consistencyWarning}`);
341
+ }
342
+
343
+ return result.workspaceDir;
96
344
  }
97
345
 
98
346
  export class WorkspaceResolutionError extends Error {
@@ -7,36 +7,44 @@ const INDEX_TS = fs.readFileSync(
7
7
  'utf-8',
8
8
  );
9
9
 
10
+ const WORKSPACE_RESOLVER_TS = fs.readFileSync(
11
+ path.resolve(__dirname, '../src/utils/workspace-resolver.ts'),
12
+ 'utf-8',
13
+ );
14
+
10
15
  describe('Hook workspace resolution NextAction contract', () => {
11
- const FORBIDDEN_NEXT_ACTION_PATTERNS = [
12
- /PD_WORKSPACE_DIR/,
13
- /principles-disciple\.json/,
14
- ];
15
-
16
- it('does not claim PD_WORKSPACE_DIR env var as recovery in NextAction', () => {
17
- const matches = INDEX_TS.match(/NextAction:[^`]*PD_WORKSPACE_DIR/g);
18
- expect(matches).toBeNull();
16
+ it('no stale HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION constant remains', () => {
17
+ const constantMatch = INDEX_TS.match(/HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION/);
18
+ expect(constantMatch).toBeNull();
19
19
  });
20
20
 
21
- it('does not claim principles-disciple.json as recovery in NextAction', () => {
22
- const matches = INDEX_TS.match(/NextAction:[^`]*principles-disciple\.json/g);
23
- expect(matches).toBeNull();
21
+ it('resolveHookWorkspaceDir failure result includes PD canonical config in nextAction', () => {
22
+ const nextActionMatch = WORKSPACE_RESOLVER_TS.match(
23
+ /nextAction:\s*'([^']+)'/s,
24
+ );
25
+ expect(nextActionMatch).not.toBeNull();
26
+ const nextAction = nextActionMatch![1];
27
+ expect(nextAction).toContain('PD_WORKSPACE_DIR');
28
+ expect(nextAction).toContain('principles-disciple.json');
24
29
  });
25
30
 
26
- it('all hook failure NextActions reference canonical workspace migration', () => {
27
- const nextActionLines = INDEX_TS.match(/NextAction: \${HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION}/g);
28
- expect(nextActionLines).not.toBeNull();
29
- expect(nextActionLines!.length).toBeGreaterThanOrEqual(7);
31
+ it('all hook failure paths use resolveHookWorkspaceDir with structured nextAction', () => {
32
+ const hookUsages = INDEX_TS.match(/resolveHookWorkspaceDir\(/g);
33
+ expect(hookUsages).not.toBeNull();
34
+ expect(hookUsages!.length).toBeGreaterThanOrEqual(6);
35
+
36
+ const wsResultOkChecks = INDEX_TS.match(/!wsResult\.ok/g);
37
+ expect(wsResultOkChecks).not.toBeNull();
38
+ expect(wsResultOkChecks!.length).toBeGreaterThanOrEqual(6);
39
+
40
+ const nextActionRefs = INDEX_TS.match(/wsResult\.nextAction/g);
41
+ expect(nextActionRefs).not.toBeNull();
42
+ expect(nextActionRefs!.length).toBeGreaterThanOrEqual(6);
30
43
  });
31
44
 
32
- it('HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION constant exists and does not contain forbidden patterns', () => {
33
- const constantMatch = INDEX_TS.match(
34
- /const HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION\s*=\s*'([^']+)'/,
35
- );
36
- expect(constantMatch).not.toBeNull();
37
- const constantValue = constantMatch![1];
38
- for (const pattern of FORBIDDEN_NEXT_ACTION_PATTERNS) {
39
- expect(pattern.test(constantValue)).toBe(false);
40
- }
45
+ it('resolveHookWorkspaceDir failure result has reason and nextAction fields', () => {
46
+ expect(WORKSPACE_RESOLVER_TS).toContain("reason: 'workspace_dir_unresolvable'");
47
+ expect(WORKSPACE_RESOLVER_TS).toContain('nextAction:');
48
+ expect(WORKSPACE_RESOLVER_TS).toContain('PD_WORKSPACE_DIR');
41
49
  });
42
50
  });
@@ -0,0 +1,347 @@
1
+ import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs';
5
+
6
+ import {
7
+ resolveCanonicalWorkspaceDir,
8
+ resolveHookWorkspaceDir,
9
+ resolveToolHookWorkspaceDirSafe,
10
+ } from '../../src/utils/workspace-resolver.js';
11
+ import type { CanonicalWorkspaceResult, HookWorkspaceResolutionResult } from '../../src/utils/workspace-resolver.js';
12
+
13
+ const homeDir = os.homedir();
14
+ const validWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-hook-resolver');
15
+
16
+ function ensureDir(dir: string): void {
17
+ if (!fs.existsSync(dir)) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+ }
21
+
22
+ const noCanonical: () => null = () => null;
23
+
24
+ describe('resolveCanonicalWorkspaceDir', () => {
25
+ const originalEnv = { ...process.env };
26
+
27
+ beforeEach(() => {
28
+ process.env = { ...originalEnv };
29
+ delete process.env.PD_WORKSPACE_DIR;
30
+ delete process.env.OPENCLAW_WORKSPACE;
31
+ ensureDir(validWorkspace);
32
+ });
33
+
34
+ afterEach(() => {
35
+ process.env = { ...originalEnv };
36
+ });
37
+
38
+ it('resolves from PD_WORKSPACE_DIR env var with highest priority', () => {
39
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
40
+ process.env.OPENCLAW_WORKSPACE = '/some/other/path';
41
+ const result = resolveCanonicalWorkspaceDir();
42
+ expect(result).not.toBeNull();
43
+ expect(result!.source).toBe('pd_env');
44
+ expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
45
+ });
46
+
47
+ it('resolves from OPENCLAW_WORKSPACE env var when PD_WORKSPACE_DIR is not set', () => {
48
+ process.env.OPENCLAW_WORKSPACE = validWorkspace;
49
+ const result = resolveCanonicalWorkspaceDir();
50
+ expect(result).not.toBeNull();
51
+ expect(result!.source).toBe('openclaw_env');
52
+ expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
53
+ });
54
+
55
+ it('prefers PD_WORKSPACE_DIR over OPENCLAW_WORKSPACE', () => {
56
+ const altWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-alt');
57
+ ensureDir(altWorkspace);
58
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
59
+ process.env.OPENCLAW_WORKSPACE = altWorkspace;
60
+ const result = resolveCanonicalWorkspaceDir();
61
+ expect(result).not.toBeNull();
62
+ expect(result!.source).toBe('pd_env');
63
+ expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
64
+ });
65
+
66
+ it('rejects home directory from PD_WORKSPACE_DIR', () => {
67
+ process.env.PD_WORKSPACE_DIR = homeDir;
68
+ const result = resolveCanonicalWorkspaceDir();
69
+ if (result?.source === 'pd_env') {
70
+ expect.fail('Should not resolve home directory from PD_WORKSPACE_DIR');
71
+ }
72
+ });
73
+
74
+ it('rejects empty string from PD_WORKSPACE_DIR', () => {
75
+ process.env.PD_WORKSPACE_DIR = '';
76
+ const result = resolveCanonicalWorkspaceDir();
77
+ if (result?.source === 'pd_env') {
78
+ expect.fail('Should not resolve empty PD_WORKSPACE_DIR');
79
+ }
80
+ });
81
+
82
+ it('always returns a result when PD_WORKSPACE_DIR points to a valid dir', () => {
83
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
84
+ const result = resolveCanonicalWorkspaceDir();
85
+ expect(result).not.toBeNull();
86
+ expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
87
+ });
88
+ });
89
+
90
+ describe('resolveHookWorkspaceDir — PD canonical primary', () => {
91
+ const originalEnv = { ...process.env };
92
+ const logger = {
93
+ error: vi.fn(),
94
+ warn: vi.fn(),
95
+ info: vi.fn(),
96
+ debug: vi.fn(),
97
+ };
98
+
99
+ const api = {
100
+ runtime: {
101
+ agent: {
102
+ resolveAgentWorkspaceDir: vi.fn(),
103
+ },
104
+ },
105
+ config: {},
106
+ logger,
107
+ };
108
+
109
+ beforeEach(() => {
110
+ process.env = { ...originalEnv };
111
+ delete process.env.PD_WORKSPACE_DIR;
112
+ delete process.env.OPENCLAW_WORKSPACE;
113
+ vi.clearAllMocks();
114
+ ensureDir(validWorkspace);
115
+ });
116
+
117
+ afterEach(() => {
118
+ process.env = { ...originalEnv };
119
+ });
120
+
121
+ it('uses PD_WORKSPACE_DIR as primary source regardless of OpenClaw context', () => {
122
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
123
+ const result = resolveHookWorkspaceDir(
124
+ { workspaceDir: '/some/openclaw/path', agentId: 'main' },
125
+ api as any,
126
+ 'test',
127
+ );
128
+ expect(result.ok).toBe(true);
129
+ if (result.ok) {
130
+ expect(result.source).toBe('pd_env');
131
+ expect(result.workspaceDir).toBe(path.resolve(validWorkspace));
132
+ }
133
+ });
134
+
135
+ it('emits consistency warning when PD canonical differs from OpenClaw context', () => {
136
+ const altWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-alt-2');
137
+ ensureDir(altWorkspace);
138
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
139
+ const result = resolveHookWorkspaceDir(
140
+ { workspaceDir: altWorkspace },
141
+ api as any,
142
+ 'test',
143
+ );
144
+ expect(result.ok).toBe(true);
145
+ if (result.ok) {
146
+ expect(result.source).toBe('pd_env');
147
+ expect(result.workspaceDir).toBe(path.resolve(validWorkspace));
148
+ expect(result.consistencyWarning).toContain('differs from OpenClaw context');
149
+ }
150
+ });
151
+
152
+ it('does not emit consistency warning when PD canonical matches OpenClaw context', () => {
153
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
154
+ const result = resolveHookWorkspaceDir(
155
+ { workspaceDir: validWorkspace },
156
+ api as any,
157
+ 'test',
158
+ );
159
+ expect(result.ok).toBe(true);
160
+ if (result.ok) {
161
+ expect(result.consistencyWarning).toBeUndefined();
162
+ }
163
+ });
164
+
165
+ it('falls back to OpenClaw context when no PD explicit config exists', () => {
166
+ const result = resolveHookWorkspaceDir(
167
+ { workspaceDir: validWorkspace },
168
+ api as any,
169
+ 'test',
170
+ { canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
171
+ );
172
+ expect(result.ok).toBe(true);
173
+ if (result.ok) {
174
+ expect(result.source).toBe('openclaw_context');
175
+ expect(result.workspaceDir).toBe(validWorkspace);
176
+ }
177
+ });
178
+
179
+ it('falls back to OpenClaw API when context is also missing', () => {
180
+ api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(validWorkspace);
181
+ const result = resolveHookWorkspaceDir(
182
+ {},
183
+ api as any,
184
+ 'test',
185
+ { canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
186
+ );
187
+ expect(result.ok).toBe(true);
188
+ if (result.ok) {
189
+ expect(result.source).toBe('openclaw_api');
190
+ expect(result.workspaceDir).toBe(validWorkspace);
191
+ }
192
+ });
193
+
194
+ it('returns structured failure with reason and nextAction when all sources fail', () => {
195
+ api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
196
+ const result = resolveHookWorkspaceDir(
197
+ {},
198
+ api as any,
199
+ 'test_hook',
200
+ { canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
201
+ );
202
+ expect(result.ok).toBe(false);
203
+ if (!result.ok) {
204
+ expect(result.reason).toBe('workspace_dir_unresolvable');
205
+ expect(result.nextAction).toContain('PD_WORKSPACE_DIR');
206
+ expect(result.nextAction).toContain('principles-disciple.json');
207
+ expect(result.message).toContain('test_hook');
208
+ }
209
+ });
210
+
211
+ it('rejects home directory from OpenClaw context and falls back to API', () => {
212
+ api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(validWorkspace);
213
+ const result = resolveHookWorkspaceDir(
214
+ { workspaceDir: homeDir, agentId: 'main' },
215
+ api as any,
216
+ 'test',
217
+ { canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
218
+ );
219
+ expect(result.ok).toBe(true);
220
+ if (result.ok) {
221
+ expect(result.source).toBe('openclaw_api');
222
+ expect(result.workspaceDir).toBe(validWorkspace);
223
+ }
224
+ });
225
+
226
+ it('ctx.workspaceDir takes priority over pd_default when no explicit PD source exists', () => {
227
+ // canonicalResolver returns pd_default, but ctx.workspaceDir is a real workspace
228
+ const pdDefaultResolver = (): CanonicalWorkspaceResult => ({
229
+ workspaceDir: path.join(homeDir, '.openclaw', 'workspace'),
230
+ source: 'pd_default',
231
+ });
232
+ const result = resolveHookWorkspaceDir(
233
+ { workspaceDir: validWorkspace },
234
+ api as any,
235
+ 'test',
236
+ { canonicalResolver: pdDefaultResolver, explicitPdResolver: noCanonical },
237
+ );
238
+ expect(result.ok).toBe(true);
239
+ if (result.ok) {
240
+ expect(result.source).toBe('openclaw_context');
241
+ expect(result.workspaceDir).toBe(validWorkspace);
242
+ }
243
+ });
244
+
245
+ it('returns failure when API throws and no other source works', () => {
246
+ api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
247
+ throw new Error('API unavailable');
248
+ });
249
+ const result = resolveHookWorkspaceDir(
250
+ {},
251
+ api as any,
252
+ 'test_hook',
253
+ { canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
254
+ );
255
+ expect(result.ok).toBe(false);
256
+ if (!result.ok) {
257
+ expect(result.reason).toBe('workspace_dir_unresolvable');
258
+ expect(result.nextAction).toContain('PD_WORKSPACE_DIR');
259
+ }
260
+ });
261
+ });
262
+
263
+ describe('resolveToolHookWorkspaceDirSafe (backward compat)', () => {
264
+ const originalEnv = { ...process.env };
265
+ const logger = {
266
+ error: vi.fn(),
267
+ warn: vi.fn(),
268
+ info: vi.fn(),
269
+ debug: vi.fn(),
270
+ };
271
+
272
+ const api = {
273
+ runtime: {
274
+ agent: {
275
+ resolveAgentWorkspaceDir: vi.fn(),
276
+ },
277
+ },
278
+ config: {},
279
+ logger,
280
+ };
281
+
282
+ beforeEach(() => {
283
+ process.env = { ...originalEnv };
284
+ delete process.env.PD_WORKSPACE_DIR;
285
+ delete process.env.OPENCLAW_WORKSPACE;
286
+ vi.clearAllMocks();
287
+ ensureDir(validWorkspace);
288
+ });
289
+
290
+ afterEach(() => {
291
+ process.env = { ...originalEnv };
292
+ });
293
+
294
+ it('returns string when PD_WORKSPACE_DIR resolves', () => {
295
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
296
+ const result = resolveToolHookWorkspaceDirSafe({}, api as any, 'test');
297
+ expect(result).toBe(path.resolve(validWorkspace));
298
+ });
299
+
300
+ it('logs consistency warning when PD canonical differs from context', () => {
301
+ const altWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-alt-3');
302
+ ensureDir(altWorkspace);
303
+ process.env.PD_WORKSPACE_DIR = validWorkspace;
304
+ const result = resolveToolHookWorkspaceDirSafe(
305
+ { workspaceDir: altWorkspace },
306
+ api as any,
307
+ 'test',
308
+ );
309
+ expect(result).toBe(path.resolve(validWorkspace));
310
+ expect(logger.warn).toHaveBeenCalledWith(
311
+ expect.stringContaining('differs from OpenClaw context'),
312
+ );
313
+ });
314
+
315
+ it('returns pd_default when only default fallback is available', () => {
316
+ // When no explicit PD source, no ctx.workspaceDir, and no API resolution,
317
+ // resolveToolHookWorkspaceDirSafe falls back to pd_default
318
+ api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
319
+ const result = resolveToolHookWorkspaceDirSafe(
320
+ {},
321
+ api as any,
322
+ 'test',
323
+ );
324
+ // pd_default (~/.openclaw/workspace) is used as last resort
325
+ expect(result).toBeDefined();
326
+ expect(result).toContain('.openclaw');
327
+ });
328
+
329
+ it('returns undefined and logs when all sources including pd_default fail', () => {
330
+ api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
331
+ throw new Error('no workspace');
332
+ });
333
+ const result = resolveToolHookWorkspaceDirSafe(
334
+ {},
335
+ api as any,
336
+ 'test_hook',
337
+ { canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
338
+ );
339
+ expect(result).toBeUndefined();
340
+ expect(logger.warn).toHaveBeenCalled();
341
+ const warnCalls = logger.warn.mock.calls.map((c: unknown[]) => String(c[0]));
342
+ const fullWarn = warnCalls.join('\n');
343
+ expect(fullWarn).toContain('Cannot resolve workspace directory');
344
+ expect(fullWarn).toContain('PD_WORKSPACE_DIR');
345
+ expect(fullWarn).toContain('principles-disciple.json');
346
+ });
347
+ });