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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/hooks/prompt.ts +4 -5
- package/src/index.ts +62 -43
- package/src/utils/workspace-resolver.ts +250 -2
- package/tests/core/workspace-guidance-migrator.test.ts +49 -2
- package/tests/evolution-worker-slimming.test.ts +323 -0
- package/tests/hook-workspace-nextaction-contract.test.ts +32 -24
- package/tests/integration/mvp-surface-registry-guard.test.ts +12 -3
- package/tests/utils/hook-workspace-resolver.test.ts +347 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/hooks/prompt.ts
CHANGED
|
@@ -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
|
|
239
|
-
if (!
|
|
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: ${
|
|
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
|
|
317
|
-
if (!
|
|
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: ${
|
|
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
|
|
352
|
-
if (!
|
|
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: ${
|
|
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
|
|
385
|
-
if (!
|
|
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: ${
|
|
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
|
|
529
|
-
if (!
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
542
|
-
if (!
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
555
|
-
if (!
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
233
|
+
if (String(p).includes(skillsPattern)) return true;
|
|
187
234
|
return false;
|
|
188
235
|
});
|
|
189
236
|
mockFs.readdirSync.mockImplementation(() => {
|