principles-disciple 1.86.0 → 1.88.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/core/pd-config-loader.ts +400 -0
- package/src/core/runtime-v2-prompt-activation-reader.ts +15 -63
- package/src/core/surface-guard.ts +62 -4
- package/src/index.ts +8 -56
- package/src/service/correction-observer-service.ts +62 -31
- package/tests/core/pd-config-loader.test.ts +407 -0
- package/tests/core/surface-guard.test.ts +142 -0
- package/tests/core-anti-growth.test.ts +1 -0
- package/tests/evolution-worker-quarantine.test.ts +83 -27
- package/tests/evolution-worker-slimming.test.ts +63 -5
- package/tests/hooks/runtime-v2-prompt-activation.test.ts +9 -3
- package/tests/integration/mvp-surface-registry-guard.test.ts +131 -1
- package/tests/service/correction-observer-service.test.ts +147 -21
- package/tests/service/evolution-worker.correction-observer.test.ts +1 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PD Config Loader (Plugin I/O boundary) — PRI-307
|
|
3
|
+
*
|
|
4
|
+
* Reads `.pd/config.yaml`, validates via core, computes effective config.
|
|
5
|
+
* Replaces the old `.pd/feature-flags.yaml` and `.state/workflows.yaml` reading
|
|
6
|
+
* for plugin production paths.
|
|
7
|
+
*
|
|
8
|
+
* ADR-0016: PD owns exactly one user config file.
|
|
9
|
+
* - Missing config → defaults with nextAction
|
|
10
|
+
* - Malformed config → fail loud with errors and nextAction
|
|
11
|
+
* - No secrets in output
|
|
12
|
+
* - Observer disabled → no start / no noisy log cycling
|
|
13
|
+
* - Observer enabled + missing setup → structured needs_setup + nextAction
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import yaml from 'js-yaml';
|
|
19
|
+
import {
|
|
20
|
+
validatePdConfig,
|
|
21
|
+
computeEffectivePdConfig,
|
|
22
|
+
computeFeatureFlagsFromConfig,
|
|
23
|
+
INTERNAL_AGENT_NAMES,
|
|
24
|
+
} from '@principles/core/runtime-v2';
|
|
25
|
+
import type {
|
|
26
|
+
EffectivePdConfig,
|
|
27
|
+
PdConfigValidationResult,
|
|
28
|
+
InternalAgentName,
|
|
29
|
+
} from '@principles/core/runtime-v2';
|
|
30
|
+
|
|
31
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const PD_CONFIG_DIR = '.pd';
|
|
34
|
+
export const PD_CONFIG_FILENAME = 'config.yaml';
|
|
35
|
+
|
|
36
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export type ObserverReadiness = 'disabled' | 'needs_setup' | 'ready' | 'not_ready' | 'config_malformed';
|
|
39
|
+
|
|
40
|
+
export interface ObserverConfigResult {
|
|
41
|
+
/** Whether the observer feature is enabled in config */
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
/** Observer readiness state */
|
|
44
|
+
readiness: ObserverReadiness;
|
|
45
|
+
/** Config source: 'defaults' | 'user_config' | 'malformed' */
|
|
46
|
+
source: string;
|
|
47
|
+
/** Reason for current state */
|
|
48
|
+
reason: string;
|
|
49
|
+
/** What the user should do next */
|
|
50
|
+
nextAction: string;
|
|
51
|
+
/** The runtime profile ID for this observer, if configured */
|
|
52
|
+
runtimeProfileId: string | null;
|
|
53
|
+
/** The runtime profile type, if configured */
|
|
54
|
+
runtimeProfileType: string | null;
|
|
55
|
+
/** The apiKeyEnv for the runtime profile, if applicable */
|
|
56
|
+
apiKeyEnv: string | null;
|
|
57
|
+
/** Whether the apiKeyEnv is present in process.env */
|
|
58
|
+
apiKeyPresent: boolean;
|
|
59
|
+
/** Provider name from runtime profile */
|
|
60
|
+
provider: string | null;
|
|
61
|
+
/** Model name from runtime profile */
|
|
62
|
+
model: string | null;
|
|
63
|
+
/** Timeout from runtime profile */
|
|
64
|
+
timeoutMs: number | null;
|
|
65
|
+
/** Base URL from runtime profile */
|
|
66
|
+
baseUrl: string | null;
|
|
67
|
+
/** Config validation errors (only present when readiness=config_malformed) */
|
|
68
|
+
configErrors?: Array<{ path: string; reason: string; nextAction: string }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PluginConfigLoadResult {
|
|
72
|
+
ok: boolean;
|
|
73
|
+
effective: EffectivePdConfig;
|
|
74
|
+
source: 'defaults' | 'user_config' | 'malformed';
|
|
75
|
+
configPath: string;
|
|
76
|
+
warnings: string[];
|
|
77
|
+
errors: Array<{ path: string; reason: string; nextAction: string }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Config Path ──────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export function getPdConfigPath(workspaceDir: string): string {
|
|
83
|
+
return path.join(workspaceDir, PD_CONFIG_DIR, PD_CONFIG_FILENAME);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Load PD Config ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Load and validate `.pd/config.yaml` from the workspace.
|
|
90
|
+
* Never throws on malformed input. Always provides a usable fallback.
|
|
91
|
+
*/
|
|
92
|
+
export function loadPdConfigForPlugin(workspaceDir: string): PluginConfigLoadResult {
|
|
93
|
+
const configPath = getPdConfigPath(workspaceDir);
|
|
94
|
+
|
|
95
|
+
// 1) Config file missing → use defaults
|
|
96
|
+
if (!fs.existsSync(configPath)) {
|
|
97
|
+
const effective = computeEffectivePdConfig(null);
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
effective,
|
|
101
|
+
source: 'defaults',
|
|
102
|
+
configPath,
|
|
103
|
+
warnings: effective.warnings,
|
|
104
|
+
errors: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2) Read the file
|
|
109
|
+
let raw: string;
|
|
110
|
+
try {
|
|
111
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
const effective = computeEffectivePdConfig(null);
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
effective,
|
|
118
|
+
source: 'malformed',
|
|
119
|
+
configPath,
|
|
120
|
+
warnings: [],
|
|
121
|
+
errors: [{ path: '', reason: `Failed to read .pd/config.yaml: ${message}`, nextAction: 'Check file permissions for .pd/config.yaml' }],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3) Parse YAML — treat as unknown (ERR-001)
|
|
126
|
+
let parsed: unknown;
|
|
127
|
+
try {
|
|
128
|
+
parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
131
|
+
const effective = computeEffectivePdConfig(null);
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
effective,
|
|
135
|
+
source: 'malformed',
|
|
136
|
+
configPath,
|
|
137
|
+
warnings: [],
|
|
138
|
+
errors: [{ path: '', reason: `YAML parse error in .pd/config.yaml: ${message}`, nextAction: 'Fix YAML syntax in .pd/config.yaml' }],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 4) Validate via core (ERR-001, ERR-005: no `as` bypasses)
|
|
143
|
+
const validationResult: PdConfigValidationResult = validatePdConfig(parsed);
|
|
144
|
+
|
|
145
|
+
if (!validationResult.ok) {
|
|
146
|
+
const effective = computeEffectivePdConfig(null);
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
effective,
|
|
150
|
+
source: 'malformed',
|
|
151
|
+
configPath,
|
|
152
|
+
warnings: [],
|
|
153
|
+
errors: validationResult.errors.map(e => ({
|
|
154
|
+
path: e.path,
|
|
155
|
+
reason: e.reason,
|
|
156
|
+
nextAction: e.nextAction,
|
|
157
|
+
})),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 5) Compute effective config
|
|
162
|
+
const effective = computeEffectivePdConfig(validationResult.value);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
effective,
|
|
167
|
+
source: 'user_config',
|
|
168
|
+
configPath,
|
|
169
|
+
warnings: effective.warnings,
|
|
170
|
+
errors: [],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Feature Flag from Config ─────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get a single feature flag's enabled state from .pd/config.yaml.
|
|
178
|
+
* Replaces the old `loadFeatureFlagFromWorkspace` which read .pd/feature-flags.yaml.
|
|
179
|
+
*/
|
|
180
|
+
export function loadFeatureFlagFromConfig(
|
|
181
|
+
workspaceDir: string,
|
|
182
|
+
flagId: string,
|
|
183
|
+
logger?: { warn?: (msg: string) => void; info?: (msg: string) => void },
|
|
184
|
+
): { enabled: boolean; source: string } {
|
|
185
|
+
const result = loadPdConfigForPlugin(workspaceDir);
|
|
186
|
+
const flags = computeFeatureFlagsFromConfig(result.effective);
|
|
187
|
+
const flag = flags.flags[flagId];
|
|
188
|
+
|
|
189
|
+
if (!result.ok) {
|
|
190
|
+
logger?.warn?.(`[PD:Config] Config validation failed: ${result.errors.map(e => e.reason).join('; ')} — using defaults`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
enabled: flag?.enabled ?? false,
|
|
195
|
+
source: result.source,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Observer Config Resolution ───────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve observer configuration from .pd/config.yaml.
|
|
203
|
+
*
|
|
204
|
+
* Returns structured state:
|
|
205
|
+
* - config_malformed: config file is invalid — no guessing, fail loud
|
|
206
|
+
* - disabled: observer feature flag is off OR agent.enabled=false → no start, no noisy logs
|
|
207
|
+
* - needs_setup: observer enabled but runtime profile missing, API key not set, or unsupported profile type
|
|
208
|
+
* - ready: observer enabled and fully configured (pi-ai with key present)
|
|
209
|
+
* - not_ready: observer enabled, API key present, but runtime availability unknown
|
|
210
|
+
*/
|
|
211
|
+
export function resolveObserverConfig(
|
|
212
|
+
workspaceDir: string,
|
|
213
|
+
observerFlagId: string,
|
|
214
|
+
observerAgentName: string,
|
|
215
|
+
_logger?: { warn?: (msg: string) => void; info?: (msg: string) => void; debug?: (msg: string) => void },
|
|
216
|
+
): ObserverConfigResult {
|
|
217
|
+
const result = loadPdConfigForPlugin(workspaceDir);
|
|
218
|
+
|
|
219
|
+
// 0) Malformed config → fail loud, do NOT swallow as "disabled"
|
|
220
|
+
if (!result.ok) {
|
|
221
|
+
return {
|
|
222
|
+
enabled: false,
|
|
223
|
+
readiness: 'config_malformed',
|
|
224
|
+
source: 'malformed',
|
|
225
|
+
reason: `Config validation failed: ${result.errors.map(e => e.reason).join('; ')}`,
|
|
226
|
+
nextAction: result.errors[0]?.nextAction ?? 'Fix .pd/config.yaml and retry',
|
|
227
|
+
runtimeProfileId: null,
|
|
228
|
+
runtimeProfileType: null,
|
|
229
|
+
apiKeyEnv: null,
|
|
230
|
+
apiKeyPresent: false,
|
|
231
|
+
provider: null,
|
|
232
|
+
model: null,
|
|
233
|
+
timeoutMs: null,
|
|
234
|
+
baseUrl: null,
|
|
235
|
+
configErrors: result.errors,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const config = result.effective.config;
|
|
240
|
+
|
|
241
|
+
// 1) Check if the observer feature flag is enabled
|
|
242
|
+
const featureFlag = config.features[observerFlagId];
|
|
243
|
+
if (!featureFlag || !featureFlag.enabled) {
|
|
244
|
+
return {
|
|
245
|
+
enabled: false,
|
|
246
|
+
readiness: 'disabled',
|
|
247
|
+
source: result.source,
|
|
248
|
+
reason: `${observerFlagId} is disabled in .pd/config.yaml`,
|
|
249
|
+
nextAction: `Set features.${observerFlagId}.enabled=true in .pd/config.yaml to enable`,
|
|
250
|
+
runtimeProfileId: null,
|
|
251
|
+
runtimeProfileType: null,
|
|
252
|
+
apiKeyEnv: null,
|
|
253
|
+
apiKeyPresent: false,
|
|
254
|
+
provider: null,
|
|
255
|
+
model: null,
|
|
256
|
+
timeoutMs: null,
|
|
257
|
+
baseUrl: null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2) Check if the agent itself is enabled (feature flag ≠ agent.enabled)
|
|
262
|
+
const knownNames: readonly string[] = INTERNAL_AGENT_NAMES;
|
|
263
|
+
if (!knownNames.includes(observerAgentName)) {
|
|
264
|
+
return {
|
|
265
|
+
enabled: false,
|
|
266
|
+
readiness: 'needs_setup',
|
|
267
|
+
source: result.source,
|
|
268
|
+
reason: `Unknown agent name '${observerAgentName}'`,
|
|
269
|
+
nextAction: `Use one of the known agent names: ${INTERNAL_AGENT_NAMES.join(', ')}`,
|
|
270
|
+
runtimeProfileId: null,
|
|
271
|
+
runtimeProfileType: null,
|
|
272
|
+
apiKeyEnv: null,
|
|
273
|
+
apiKeyPresent: false,
|
|
274
|
+
provider: null,
|
|
275
|
+
model: null,
|
|
276
|
+
timeoutMs: null,
|
|
277
|
+
baseUrl: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const agentKey = observerAgentName as InternalAgentName;
|
|
281
|
+
const agentConfig = config.internalAgents.agents[agentKey];
|
|
282
|
+
|
|
283
|
+
// Feature flag on but agent.enabled=false → disabled (not enabled)
|
|
284
|
+
if (!agentConfig || !agentConfig.enabled) {
|
|
285
|
+
return {
|
|
286
|
+
enabled: false,
|
|
287
|
+
readiness: 'disabled',
|
|
288
|
+
source: result.source,
|
|
289
|
+
reason: `${observerFlagId} feature flag is enabled but internalAgents.agents.${observerAgentName}.enabled is false`,
|
|
290
|
+
nextAction: `Set internalAgents.agents.${observerAgentName}.enabled=true in .pd/config.yaml, or disable features.${observerFlagId}`,
|
|
291
|
+
runtimeProfileId: null,
|
|
292
|
+
runtimeProfileType: null,
|
|
293
|
+
apiKeyEnv: null,
|
|
294
|
+
apiKeyPresent: false,
|
|
295
|
+
provider: null,
|
|
296
|
+
model: null,
|
|
297
|
+
timeoutMs: null,
|
|
298
|
+
baseUrl: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 3) Find the agent's runtime profile
|
|
303
|
+
const runtimeProfileId = agentConfig.runtimeProfile ?? config.internalAgents.defaultRuntime;
|
|
304
|
+
const profile = config.runtimeProfiles[runtimeProfileId];
|
|
305
|
+
|
|
306
|
+
if (!profile) {
|
|
307
|
+
return {
|
|
308
|
+
enabled: true,
|
|
309
|
+
readiness: 'needs_setup',
|
|
310
|
+
source: result.source,
|
|
311
|
+
reason: `Runtime profile '${runtimeProfileId}' not found in .pd/config.yaml`,
|
|
312
|
+
nextAction: `Add runtime profile '${runtimeProfileId}' to .pd/config.yaml runtimeProfiles`,
|
|
313
|
+
runtimeProfileId,
|
|
314
|
+
runtimeProfileType: null,
|
|
315
|
+
apiKeyEnv: null,
|
|
316
|
+
apiKeyPresent: false,
|
|
317
|
+
provider: null,
|
|
318
|
+
model: null,
|
|
319
|
+
timeoutMs: null,
|
|
320
|
+
baseUrl: null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 4) For pi-ai profiles, check API key
|
|
325
|
+
if (profile.type === 'pi-ai') {
|
|
326
|
+
const apiKeyEnv = profile.apiKeyEnv ?? null;
|
|
327
|
+
const apiKeyPresent = !!apiKeyEnv && Object.prototype.hasOwnProperty.call(process.env, apiKeyEnv) && !!process.env[apiKeyEnv];
|
|
328
|
+
|
|
329
|
+
if (!apiKeyEnv) {
|
|
330
|
+
return {
|
|
331
|
+
enabled: true,
|
|
332
|
+
readiness: 'needs_setup',
|
|
333
|
+
source: result.source,
|
|
334
|
+
reason: `pi-ai profile '${runtimeProfileId}' missing apiKeyEnv`,
|
|
335
|
+
nextAction: `Add apiKeyEnv to runtime profile '${runtimeProfileId}' in .pd/config.yaml`,
|
|
336
|
+
runtimeProfileId,
|
|
337
|
+
runtimeProfileType: profile.type,
|
|
338
|
+
apiKeyEnv: null,
|
|
339
|
+
apiKeyPresent: false,
|
|
340
|
+
provider: profile.provider ?? null,
|
|
341
|
+
model: profile.model ?? null,
|
|
342
|
+
timeoutMs: profile.timeoutMs ?? null,
|
|
343
|
+
baseUrl: profile.baseUrl ?? null,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!apiKeyPresent) {
|
|
348
|
+
return {
|
|
349
|
+
enabled: true,
|
|
350
|
+
readiness: 'needs_setup',
|
|
351
|
+
source: result.source,
|
|
352
|
+
reason: `Environment variable '${apiKeyEnv}' is not set or empty`,
|
|
353
|
+
nextAction: `Set the environment variable '${apiKeyEnv}' with a valid API key`,
|
|
354
|
+
runtimeProfileId,
|
|
355
|
+
runtimeProfileType: profile.type,
|
|
356
|
+
apiKeyEnv,
|
|
357
|
+
apiKeyPresent: false,
|
|
358
|
+
provider: profile.provider ?? null,
|
|
359
|
+
model: profile.model ?? null,
|
|
360
|
+
timeoutMs: profile.timeoutMs ?? null,
|
|
361
|
+
baseUrl: profile.baseUrl ?? null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// pi-ai with key present — runtime availability unknown without actual probe
|
|
366
|
+
return {
|
|
367
|
+
enabled: true,
|
|
368
|
+
readiness: 'not_ready',
|
|
369
|
+
source: result.source,
|
|
370
|
+
reason: `pi-ai profile configured with apiKeyEnv='${apiKeyEnv}' (key present); runtime availability unknown`,
|
|
371
|
+
nextAction: 'Run pd runtime probe to verify end-to-end connectivity',
|
|
372
|
+
runtimeProfileId,
|
|
373
|
+
runtimeProfileType: profile.type,
|
|
374
|
+
apiKeyEnv,
|
|
375
|
+
apiKeyPresent: true,
|
|
376
|
+
provider: profile.provider ?? null,
|
|
377
|
+
model: profile.model ?? null,
|
|
378
|
+
timeoutMs: profile.timeoutMs ?? null,
|
|
379
|
+
baseUrl: profile.baseUrl ?? null,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 5) OpenClaw profile — CorrectionObserver does NOT support OpenClaw runtime
|
|
384
|
+
// Mark as needs_setup with nextAction to configure a pi-ai profile
|
|
385
|
+
return {
|
|
386
|
+
enabled: true,
|
|
387
|
+
readiness: 'needs_setup',
|
|
388
|
+
source: result.source,
|
|
389
|
+
reason: `OpenClaw profile '${runtimeProfileId}' is not supported for observer runtime. Observers require a pi-ai profile with an API key.`,
|
|
390
|
+
nextAction: `Configure a pi-ai runtime profile for ${observerAgentName} in .pd/config.yaml (e.g., add a pi-ai profile with provider, model, and apiKeyEnv)`,
|
|
391
|
+
runtimeProfileId,
|
|
392
|
+
runtimeProfileType: profile.type,
|
|
393
|
+
apiKeyEnv: null,
|
|
394
|
+
apiKeyPresent: false,
|
|
395
|
+
provider: profile.provider ?? null,
|
|
396
|
+
model: profile.model ?? null,
|
|
397
|
+
timeoutMs: null,
|
|
398
|
+
baseUrl: null,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import { SqliteConnection, SqliteActivationStateStore, computeEffectiveFlags, DEFAULT_FEATURE_FLAGS, filterPromptActivations, resolvePrincipleFromArtifact } from '@principles/core/runtime-v2';
|
|
5
|
-
import type { EffectiveFeatureFlags, ActivatedPrinciple, PromptActivationReaderResult } from '@principles/core/runtime-v2';
|
|
1
|
+
import { SqliteConnection, SqliteActivationStateStore, computeFeatureFlagsFromConfig, filterPromptActivations, resolvePrincipleFromArtifact } from '@principles/core/runtime-v2';
|
|
2
|
+
import type { FeatureFlagsResult, ActivatedPrinciple, PromptActivationReaderResult } from '@principles/core/runtime-v2';
|
|
3
|
+
import { loadPdConfigForPlugin } from './pd-config-loader.js';
|
|
6
4
|
|
|
7
5
|
export { RUNTIME_V2_PRINCIPLE_BUDGET } from '@principles/core/runtime-v2';
|
|
8
6
|
export type { ActivatedPrinciple, PromptActivationReaderResult };
|
|
@@ -11,12 +9,6 @@ export interface PromptActivationReaderDeps {
|
|
|
11
9
|
logger?: { warn?: (msg: string) => void; info?: (msg: string) => void; error?: (msg: string) => void };
|
|
12
10
|
}
|
|
13
11
|
|
|
14
|
-
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
15
|
-
|
|
16
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
12
|
export class PromptActivationReader {
|
|
21
13
|
private readonly workspaceDir: string;
|
|
22
14
|
private readonly deps: PromptActivationReaderDeps;
|
|
@@ -103,60 +95,20 @@ export class PromptActivationReader {
|
|
|
103
95
|
}
|
|
104
96
|
}
|
|
105
97
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
118
|
-
this.deps.logger?.warn?.(`[PD:RuntimeV2] Feature flags unreadable: ${msg} — using defaults`);
|
|
119
|
-
return computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
let parsed: unknown;
|
|
123
|
-
try {
|
|
124
|
-
parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
125
|
-
} catch {
|
|
126
|
-
this.deps.logger?.warn?.(`[PD:RuntimeV2] Feature flags YAML parse error — using defaults`);
|
|
127
|
-
return {
|
|
128
|
-
...computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath),
|
|
129
|
-
warnings: ['feature-flags.yaml: YAML parse error, using defaults'],
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (!isRecord(parsed)) {
|
|
134
|
-
this.deps.logger?.warn?.(`[PD:RuntimeV2] Feature flags not a mapping — using defaults`);
|
|
135
|
-
return {
|
|
136
|
-
...computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath),
|
|
137
|
-
warnings: ['feature-flags.yaml: expected a mapping, using defaults'],
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const parsedRecord: Record<string, unknown> = Object.create(null);
|
|
142
|
-
const yamlWarnings: string[] = [];
|
|
143
|
-
for (const key of Object.keys(parsed)) {
|
|
144
|
-
if (DANGEROUS_KEYS.has(key)) {
|
|
145
|
-
yamlWarnings.push(`feature-flags.yaml: dangerous key '${key}' rejected`);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (Object.hasOwn(parsed, key)) {
|
|
149
|
-
parsedRecord[key] = parsed[key];
|
|
98
|
+
/**
|
|
99
|
+
* PRI-305/PRI-307: Load feature flags from .pd/config.yaml instead of .pd/feature-flags.yaml.
|
|
100
|
+
* Uses the shared plugin config loader for consistency.
|
|
101
|
+
*/
|
|
102
|
+
private loadFeatureFlags(): FeatureFlagsResult {
|
|
103
|
+
const result = loadPdConfigForPlugin(this.workspaceDir);
|
|
104
|
+
const flags = computeFeatureFlagsFromConfig(result.effective);
|
|
105
|
+
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
for (const err of result.errors) {
|
|
108
|
+
this.deps.logger?.warn?.(`[PD:RuntimeV2] Config error at ${err.path}: ${err.reason}`);
|
|
150
109
|
}
|
|
151
110
|
}
|
|
152
111
|
|
|
153
|
-
|
|
154
|
-
if (yamlWarnings.length > 0) {
|
|
155
|
-
result.warnings = [...yamlWarnings, ...result.warnings];
|
|
156
|
-
for (const w of yamlWarnings) {
|
|
157
|
-
this.deps.logger?.warn?.(`[PD:RuntimeV2] ${w}`);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return result;
|
|
112
|
+
return flags;
|
|
161
113
|
}
|
|
162
114
|
}
|
|
@@ -15,6 +15,53 @@ export interface SurfaceGuardResult {
|
|
|
15
15
|
warnings: string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Surface-level once-only log state (PRI-298).
|
|
19
|
+
// The first time a quiet/non-core surface guard actually fires in this
|
|
20
|
+
// process, the disabled reason is emitted once. Subsequent fires for the
|
|
21
|
+
// same surfaceId are still observable (the no-op handler preserves
|
|
22
|
+
// behaviour) but no longer flood the log. Fresh processes start with an
|
|
23
|
+
// empty set, so each plugin load gets one observable skip per surface.
|
|
24
|
+
//
|
|
25
|
+
// The Set is only updated when the log was actually emitted, so passing
|
|
26
|
+
// `undefined` for the logger on a quiet first fire does NOT consume the
|
|
27
|
+
// one-shot slot — a later registration that supplies a logger still gets
|
|
28
|
+
// the first-fire reason (PRI-298 / ERR-002).
|
|
29
|
+
const loggedSkipSurfaces = new Set<string>();
|
|
30
|
+
|
|
31
|
+
type LoggerLike = { info?: (msg: string) => void; debug?: (msg: string) => void };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Emit the disabled-reason log line for `surfaceId` at most once per
|
|
35
|
+
* process. Returns true if the log was emitted, false if it was suppressed
|
|
36
|
+
* (already logged, or no logger available). Only marks the surface as
|
|
37
|
+
* logged when the log was actually written, so a missing logger on first
|
|
38
|
+
* call does not consume the one-shot slot.
|
|
39
|
+
*/
|
|
40
|
+
function logSkipOnce(
|
|
41
|
+
surfaceId: string,
|
|
42
|
+
logger: LoggerLike | undefined,
|
|
43
|
+
message: string,
|
|
44
|
+
): boolean {
|
|
45
|
+
if (loggedSkipSurfaces.has(surfaceId)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (!logger?.info) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
loggedSkipSurfaces.add(surfaceId);
|
|
52
|
+
logger.info(message);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reset the per-process surface-guard skip log bookkeeping. Intended for tests
|
|
58
|
+
* that need to assert on the first-fire log without cross-test pollution.
|
|
59
|
+
* Not part of the production API surface; do not call from runtime code.
|
|
60
|
+
*/
|
|
61
|
+
export function __resetSurfaceGuardSkipLogStateForTests(): void {
|
|
62
|
+
loggedSkipSurfaces.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
18
65
|
export function checkSurfaceGuard(): SurfaceGuardResult {
|
|
19
66
|
const validation = validateSurfaceRegistry(PLUGIN_SURFACE_REGISTRY);
|
|
20
67
|
const violations: string[] = [];
|
|
@@ -98,7 +145,7 @@ export type HookHandler<E, C, R> = (event: E, ctx: C) => R | Promise<R>;
|
|
|
98
145
|
|
|
99
146
|
export function guardHook<E, C, R>(
|
|
100
147
|
surfaceId: string,
|
|
101
|
-
logger:
|
|
148
|
+
logger: LoggerLike | undefined,
|
|
102
149
|
handler: HookHandler<E, C, R>,
|
|
103
150
|
): HookHandler<E, C, R> {
|
|
104
151
|
const check = isSurfaceEnabled(surfaceId);
|
|
@@ -106,8 +153,15 @@ export function guardHook<E, C, R>(
|
|
|
106
153
|
return handler;
|
|
107
154
|
}
|
|
108
155
|
const reason = check.reason ?? 'surface not enabled';
|
|
156
|
+
// Log on the first ACTUAL no-op invocation, not at construction time
|
|
157
|
+
// (PRI-298). Construction-time logging would emit a `SKIP` line at
|
|
158
|
+
// plugin startup for every registered quiet hook, regardless of
|
|
159
|
+
// whether the hook ever fires — which is exactly the startup log
|
|
160
|
+
// noise this change is meant to prevent. The one-shot is consumed only
|
|
161
|
+
// when the log was actually written, so `undefined` logger on first
|
|
162
|
+
// call does not eat the slot.
|
|
109
163
|
return (_event: E, _ctx: C): R | Promise<R> => {
|
|
110
|
-
logger
|
|
164
|
+
logSkipOnce(surfaceId, logger, `[PD:surface-guard] SKIP ${surfaceId}: ${reason}`);
|
|
111
165
|
return undefined as R;
|
|
112
166
|
};
|
|
113
167
|
}
|
|
@@ -115,14 +169,18 @@ export function guardHook<E, C, R>(
|
|
|
115
169
|
export function guardService<T extends OpenClawPluginService>(
|
|
116
170
|
surfaceId: string,
|
|
117
171
|
service: T,
|
|
118
|
-
logger?:
|
|
172
|
+
logger?: LoggerLike,
|
|
119
173
|
): T | null {
|
|
120
174
|
const check = isSurfaceEnabled(surfaceId);
|
|
121
175
|
if (check.enabled) {
|
|
122
176
|
return service;
|
|
123
177
|
}
|
|
124
178
|
const reason = check.reason ?? 'surface not enabled';
|
|
125
|
-
|
|
179
|
+
// guardService is called once per service during plugin registration,
|
|
180
|
+
// so the once-only check fires on the registration call itself. The
|
|
181
|
+
// shared helper makes the consumption rule identical to guardHook:
|
|
182
|
+
// a missing logger on first call does not consume the one-shot.
|
|
183
|
+
logSkipOnce(surfaceId, logger, `[PD:surface-guard] SKIP service ${surfaceId}: ${reason}`);
|
|
126
184
|
return null;
|
|
127
185
|
}
|
|
128
186
|
|