principles-disciple 1.83.0 → 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.0",
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.0",
3
+ "version": "1.84.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -192,7 +192,7 @@ export function loadContextInjectionConfig(workspaceDir: string): ContextInjecti
192
192
  const raw = cachedReadFile(profilePath);
193
193
  if (raw) {
194
194
  const profile = JSON.parse(raw);
195
- if (profile.contextInjection) {
195
+ if (profile && typeof profile === 'object' && profile.contextInjection && typeof profile.contextInjection === 'object') {
196
196
  const contextInjection = profile.contextInjection as Partial<ContextInjectionConfig>;
197
197
  return {
198
198
  ...defaultContextConfig,
@@ -360,6 +360,9 @@ export async function handleBeforePromptBuild(
360
360
  }
361
361
 
362
362
  // ──── 1. prependSystemContext: Minimal Agent Identity ────
363
+ // EvolutionWorker-era INTERNAL SYSTEM LAYOUT removed per PRI-294.
364
+ // The EVOLUTION_WORKER PathResolver key and system layout reference are
365
+ // not MVP-Core; agents discover what they need via tool calls.
363
366
  prependSystemContext = `## 【AGENT IDENTITY】
364
367
 
365
368
  You are a **self-evolving AI agent** powered by Principles Disciple.
@@ -377,10 +380,6 @@ You are a **self-evolving AI agent** powered by Principles Disciple.
377
380
  - Use the current session for the normal user reply.
378
381
  - Use sessions_send for cross-session messaging.
379
382
  - Use agents_list / sessions_list for peer-agent or peer-session orchestration.
380
-
381
- ## 🔧 INTERNAL SYSTEM LAYOUT
382
- - Your core plugin logic is rooted at: ${PathResolver.getExtensionRoot() || 'EXTENSION_ROOT (unresolved)'}
383
- - If you need self-inspection, prioritize the worker entry pointed by PathResolver key: EVOLUTION_WORKER
384
383
  `;
385
384
 
386
385
  // ──── 2. Empathy Observer Spawn (async sidecar)
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,49 +534,59 @@ 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
- // ── Service: Background Evolution Worker ──
584
+ // ── Service Registration (surface-guarded) ──
585
+ // PRI-294: EvolutionWorker service registration removed — it starts via
586
+ // before_prompt_build hook gate, not via api.registerService. The surface
587
+ // guard already prevents registration when disabled (enabledByDefault=false).
588
+ // Dead pre-assignment of EvolutionWorkerService.api removed.
567
589
  try {
568
- EvolutionWorkerService.api = api;
569
- const guardedEvolutionWorker = guardService('service:evolution-worker', EvolutionWorkerService, api.logger);
570
- if (guardedEvolutionWorker) api.registerService(guardedEvolutionWorker);
571
590
  const guardedCorrectionObserver = guardService('service:correction-observer', CorrectionObserverService, api.logger);
572
591
  if (guardedCorrectionObserver) api.registerService(guardedCorrectionObserver);
573
592
  const guardedTrajectory = guardService('service:trajectory', TrajectoryService, api.logger);
@@ -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 {
@@ -13,6 +13,15 @@ const mockFs = {
13
13
 
14
14
  vi.mock('fs', () => mockFs);
15
15
 
16
+ // Mock heavy core module to avoid slow re-imports and to control staleness detection
17
+ const mockMigrateWorkspaceGuidance = vi.fn<(content: string, relativePath: string) => { changed: boolean; migrated: string }>();
18
+ const mockContainsStalePlanMdGuidance = vi.fn<(content: string, relativePath: string) => boolean>();
19
+
20
+ vi.mock('@principles/core/runtime-v2', () => ({
21
+ migrateWorkspaceGuidance: (...args: unknown[]) => mockMigrateWorkspaceGuidance(...(args as [string, string])),
22
+ containsStalePlanMdGuidance: (...args: unknown[]) => mockContainsStalePlanMdGuidance(...(args as [string, string])),
23
+ }));
24
+
16
25
  const WORKSPACE_GUIDANCE_MIGRATOR_PATH = '../../src/core/workspace-guidance-migrator.js';
17
26
 
18
27
  describe('workspace-guidance-migrator', () => {
@@ -41,6 +50,13 @@ describe('workspace-guidance-migrator', () => {
41
50
  mockFs.writeFileSync.mockReturnValue(undefined);
42
51
  mockFs.readdirSync.mockReturnValue([]);
43
52
 
53
+ // Default: content is NOT stale
54
+ mockContainsStalePlanMdGuidance.mockReturnValue(false);
55
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
56
+ changed: false,
57
+ migrated: content,
58
+ }));
59
+
44
60
  const module = await import(WORKSPACE_GUIDANCE_MIGRATOR_PATH);
45
61
  migrateStaleWorkspaceGuidance = module.migrateStaleWorkspaceGuidance;
46
62
  });
@@ -63,6 +79,7 @@ describe('workspace-guidance-migrator', () => {
63
79
  it('skips files with no stale guidance', () => {
64
80
  mockFs.existsSync.mockReturnValue(true);
65
81
  mockFs.readFileSync.mockReturnValue('# Clean AGENTS.md\nNo stale references here.');
82
+ // Default: containsStalePlanMdGuidance returns false
66
83
 
67
84
  const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
68
85
 
@@ -76,6 +93,14 @@ describe('workspace-guidance-migrator', () => {
76
93
  mockFs.readFileSync.mockReturnValue(
77
94
  '# Agent Instructions\nPhysical interception ensures safety.',
78
95
  );
96
+ // First call (AGENTS.md) is stale, second (MEMORY.md) is not
97
+ mockContainsStalePlanMdGuidance
98
+ .mockReturnValueOnce(true)
99
+ .mockReturnValueOnce(false);
100
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
101
+ changed: true,
102
+ migrated: content.replace('Physical interception', 'MIGRATED'),
103
+ }));
79
104
 
80
105
  const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
81
106
 
@@ -88,6 +113,11 @@ describe('workspace-guidance-migrator', () => {
88
113
  mockFs.readFileSync.mockReturnValue(
89
114
  '# Agent Instructions\nPhysical interception ensures safety.',
90
115
  );
116
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
117
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
118
+ changed: true,
119
+ migrated: content.replace('Physical interception', 'MIGRATED'),
120
+ }));
91
121
 
92
122
  migrateStaleWorkspaceGuidance(mockApi, '/workspace');
93
123
 
@@ -102,6 +132,11 @@ describe('workspace-guidance-migrator', () => {
102
132
  mockFs.readFileSync.mockReturnValue(
103
133
  '# Agent Instructions\nPhysical interception ensures safety.',
104
134
  );
135
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
136
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
137
+ changed: true,
138
+ migrated: content.replace('Physical interception', 'MIGRATED'),
139
+ }));
105
140
 
106
141
  migrateStaleWorkspaceGuidance(mockApi, '/workspace');
107
142
 
@@ -126,6 +161,11 @@ describe('workspace-guidance-migrator', () => {
126
161
  const originalContent = '# Agent Instructions\nPhysical interception ensures safety.';
127
162
  mockFs.existsSync.mockReturnValue(true);
128
163
  mockFs.readFileSync.mockReturnValue(originalContent);
164
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
165
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
166
+ changed: true,
167
+ migrated: content.replace('Physical interception', 'MIGRATED'),
168
+ }));
129
169
 
130
170
  let callCount = 0;
131
171
  mockFs.writeFileSync.mockImplementation((path: string, content: string) => {
@@ -154,8 +194,9 @@ describe('workspace-guidance-migrator', () => {
154
194
  });
155
195
 
156
196
  it('discovers skill files in .principles/skills directory', () => {
197
+ const skillsPattern = path.join('.principles', 'skills');
157
198
  mockFs.existsSync.mockImplementation((p: string) => {
158
- if (String(p).includes('.principles/skills')) return true;
199
+ if (String(p).includes(skillsPattern)) return true;
159
200
  return false;
160
201
  });
161
202
  mockFs.readdirSync.mockReturnValue([
@@ -165,6 +206,11 @@ describe('workspace-guidance-migrator', () => {
165
206
  mockFs.readFileSync.mockReturnValue(
166
207
  'Ensure `PLAN.md` contains `## Target Files` heading.',
167
208
  );
209
+ mockContainsStalePlanMdGuidance.mockReturnValue(true);
210
+ mockMigrateWorkspaceGuidance.mockImplementation((content: string) => ({
211
+ changed: true,
212
+ migrated: content.replace('## Target Files', '## Targets'),
213
+ }));
168
214
 
169
215
  const result = migrateStaleWorkspaceGuidance(mockApi, '/workspace');
170
216
 
@@ -182,8 +228,9 @@ describe('workspace-guidance-migrator', () => {
182
228
  });
183
229
 
184
230
  it('handles skills directory read error gracefully', () => {
231
+ const skillsPattern = path.join('.principles', 'skills');
185
232
  mockFs.existsSync.mockImplementation((p: string) => {
186
- if (String(p).includes('.principles/skills')) return true;
233
+ if (String(p).includes(skillsPattern)) return true;
187
234
  return false;
188
235
  });
189
236
  mockFs.readdirSync.mockImplementation(() => {