principles-disciple 1.111.0 → 1.113.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.111.0",
5
+ "version": "1.113.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.111.0",
3
+ "version": "1.113.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
@@ -52,6 +52,7 @@ import { handleSamplesCommand } from './commands/samples.js';
52
52
  import { handleWorkflowDebugCommand } from './commands/workflow-debug.js';
53
53
  import { EvolutionWorkerService } from './service/evolution-worker.js';
54
54
  import { CorrectionObserverService } from './service/correction-observer-service.js';
55
+ import { InternalizationAutoConsumerService } from './service/internalization-auto-consumer-service.js';
55
56
  import { TrajectoryService } from './service/trajectory-service.js';
56
57
  import { PDTaskService } from './core/pd-task-service.js';
57
58
  import { CentralSyncService } from './service/central-sync-service.js';
@@ -149,6 +150,30 @@ export function shouldStartCorrectionObserver(
149
150
  return { shouldStart: false, flagSource: flag.source, disabledInfo };
150
151
  }
151
152
 
153
+ export interface InternalizationAutoConsumerGateResult {
154
+ shouldStart: boolean;
155
+ flagSource: string;
156
+ disabledInfo: string | null;
157
+ }
158
+
159
+ export function shouldStartInternalizationAutoConsumer(
160
+ workspaceDir: string,
161
+ logger: { info?: (msg: string) => void; warn?: (msg: string) => void },
162
+ ): InternalizationAutoConsumerGateResult {
163
+ const flag = loadFeatureFlagFromWorkspace(workspaceDir, 'internalization_auto_consumer', logger);
164
+ if (flag.enabled) {
165
+ return { shouldStart: true, flagSource: flag.source, disabledInfo: null };
166
+ }
167
+ const disabledInfo = JSON.stringify({
168
+ reason: 'internalization_auto_consumer_disabled',
169
+ nextAction: `pd runtime internalization run-once --workspace "${workspaceDir}" --runner dreamer --runtime config --json`,
170
+ featureFlag: 'internalization_auto_consumer',
171
+ boundedContext: 'internalization_auto_consumer',
172
+ flagSource: flag.source,
173
+ });
174
+ return { shouldStart: false, flagSource: flag.source, disabledInfo };
175
+ }
176
+
152
177
  const plugin = {
153
178
  name: "Principles Disciple",
154
179
  description: "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
@@ -258,6 +283,23 @@ const plugin = {
258
283
  api.logger.info(`[PD] CorrectionObserver NOT started for workspace: ${workspaceDir}. ${corrGate.disabledInfo}`);
259
284
  SystemLogger.log(workspaceDir, 'CORRECTION_OBSERVER_DISABLED', corrGate.disabledInfo ?? '');
260
285
  }
286
+
287
+ // ── Start InternalizationAutoConsumer for THIS workspace ──
288
+ // PRI-381: Bounded auto-consumer for dreamer ready tasks.
289
+ // Default ON for dogfood; kill switch via features.internalization_auto_consumer.enabled=false.
290
+ const autoConsGate = shouldStartInternalizationAutoConsumer(workspaceDir, api.logger);
291
+ if (autoConsGate.shouldStart) {
292
+ InternalizationAutoConsumerService.start({
293
+ config: api.config,
294
+ workspaceDir,
295
+ stateDir: path.join(workspaceDir, '.state'),
296
+ logger: api.logger,
297
+ });
298
+ api.logger.info(`[PD] InternalizationAutoConsumer started for workspace: ${workspaceDir} (flag source: ${autoConsGate.flagSource})`);
299
+ } else {
300
+ api.logger.info(`[PD] InternalizationAutoConsumer NOT started for workspace: ${workspaceDir}. ${autoConsGate.disabledInfo}`);
301
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_DISABLED', autoConsGate.disabledInfo ?? '');
302
+ }
261
303
  }
262
304
 
263
305
  const result = await handleBeforePromptBuild(event, { ...ctx, api: api as Parameters<typeof handleBeforePromptBuild>[1]['api'], workspaceDir });
@@ -562,6 +604,8 @@ const plugin = {
562
604
  if (guardedPdTask) api.registerService(guardedPdTask);
563
605
  const guardedCentralSync = guardService('service:central-sync', CentralSyncService, api.logger);
564
606
  if (guardedCentralSync) api.registerService(guardedCentralSync);
607
+ const guardedAutoConsumer = guardService('service:internalization-auto-consumer', InternalizationAutoConsumerService, api.logger);
608
+ if (guardedAutoConsumer) api.registerService(guardedAutoConsumer);
565
609
  } catch (err) {
566
610
  api.logger.error(`[PD] Failed to register services: ${String(err)}`);
567
611
  }
@@ -0,0 +1,327 @@
1
+ import type { OpenClawPluginServiceContext, PluginLogger } from '../openclaw-sdk.js';
2
+ import {
3
+ createRuntimeStateHandle,
4
+ InternalizationOrchestrator,
5
+ DreamerRunner,
6
+ DefaultDreamerValidator,
7
+ PiAiRuntimeAdapter,
8
+ storeEmitter,
9
+ resolveRuntimeConfigFromPdConfig,
10
+ isRuntimeConfigError,
11
+ computeConsumerDecision,
12
+ InternalizationQueueReadModel,
13
+ MVP_CORE_TASK_KINDS,
14
+ } from '@principles/core/runtime-v2';
15
+ import { loadPdConfigForPlugin, loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
16
+ import { SystemLogger } from '../core/system-logger.js';
17
+
18
+ const INTERNALIZATION_AUTO_CONSUMER_INTERVAL_MS = 120_000;
19
+ const INTERNALIZATION_AUTO_CONSUMER_INITIAL_DELAY_MS = 30_000;
20
+ const INTERNALIZATION_AUTO_CONSUMER_FLAG_ID = 'internalization_auto_consumer';
21
+
22
+ export interface InternalizationAutoConsumerServiceShape {
23
+ id: string;
24
+ start: (ctx: OpenClawPluginServiceContext) => void;
25
+ stop?: (ctx: OpenClawPluginServiceContext) => void;
26
+ }
27
+
28
+ interface WorkspaceConsumerState {
29
+ stopped: boolean;
30
+ timeoutId: ReturnType<typeof setTimeout> | null;
31
+ }
32
+
33
+ const workspaceStates = new Map<string, WorkspaceConsumerState>();
34
+
35
+ function getWorkspaceState(workspaceDir: string): WorkspaceConsumerState {
36
+ let state = workspaceStates.get(workspaceDir);
37
+ if (!state) {
38
+ state = { stopped: false, timeoutId: null };
39
+ workspaceStates.set(workspaceDir, state);
40
+ }
41
+ return state;
42
+ }
43
+
44
+ function formatRunOnceCommand(workspaceDir: string): string {
45
+ return `pd runtime internalization run-once --workspace "${workspaceDir}" --runner dreamer --runtime config --json`;
46
+ }
47
+
48
+ function getNextActionForError(category?: string): string {
49
+ if (category === 'lease_conflict') {
50
+ return 'Retry later or check for concurrent worker processes.';
51
+ }
52
+ if (category === 'timeout') {
53
+ return 'Check model provider service status/latency, or increase timeout settings in workflows.yaml.';
54
+ }
55
+ if (category === 'cancelled') {
56
+ return 'Re-enqueue or restart the task if it was cancelled by mistake.';
57
+ }
58
+ if (category === 'output_invalid') {
59
+ return 'Verify if model outputs conform to expected schema and adjust prompt or validation templates if needed.';
60
+ }
61
+ if (category === 'input_invalid') {
62
+ return 'Check predecessor task outputs and database integrity for malformed input references.';
63
+ }
64
+ if (category === 'max_attempts_exceeded') {
65
+ return 'Investigate persistent failures, correct the root issue, and clear last_error or reset attempt count.';
66
+ }
67
+ return 'Run: pd runtime internalization run-once --runner dreamer --runtime config --json to isolate the failure.';
68
+ }
69
+
70
+ async function runConsumerCycle(
71
+ workspaceDir: string,
72
+ logger: PluginLogger,
73
+ ): Promise<void> {
74
+ const flag = loadFeatureFlagFromConfig(workspaceDir, INTERNALIZATION_AUTO_CONSUMER_FLAG_ID, {
75
+ info: (msg: string) => logger.info(msg),
76
+ warn: (msg: string) => logger.warn(msg),
77
+ });
78
+
79
+ if (!flag.enabled) {
80
+ const disabledInfo = JSON.stringify({
81
+ reason: 'internalization_auto_consumer_disabled',
82
+ nextAction: formatRunOnceCommand(workspaceDir),
83
+ flagSource: flag.source,
84
+ });
85
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_SKIP', disabledInfo);
86
+ logger.info(`[PD:AutoConsumer] Cycle skipped: auto-consumer disabled. Source: ${flag.source}`);
87
+ return;
88
+ }
89
+
90
+ const configResult = loadPdConfigForPlugin(workspaceDir);
91
+ if (!configResult.ok) {
92
+ const malformedInfo = JSON.stringify({
93
+ reason: 'config_malformed',
94
+ nextAction: configResult.errors[0]?.nextAction ?? 'Fix .pd/config.yaml and retry',
95
+ errors: configResult.errors.map((e) => e.reason),
96
+ });
97
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_SKIP', malformedInfo);
98
+ logger.warn(`[PD:AutoConsumer] Config malformed, skipping cycle.`);
99
+ return;
100
+ }
101
+
102
+ const runtimeConfigResult = resolveRuntimeConfigFromPdConfig(
103
+ configResult.effective,
104
+ (name: string) => process.env[name],
105
+ );
106
+
107
+ if (isRuntimeConfigError(runtimeConfigResult)) {
108
+ const rtInfo = JSON.stringify({
109
+ reason: 'runtime_config_error',
110
+ message: runtimeConfigResult.message,
111
+ nextAction: runtimeConfigResult.nextAction,
112
+ });
113
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_SKIP', rtInfo);
114
+ logger.warn(`[PD:AutoConsumer] Runtime config error: ${runtimeConfigResult.message}`);
115
+ return;
116
+ }
117
+
118
+ let handle: Awaited<ReturnType<typeof createRuntimeStateHandle>> | null = null;
119
+ try {
120
+ handle = await createRuntimeStateHandle({ workspaceDir, readonly: false });
121
+ const { stateManager } = handle;
122
+
123
+ const readModel = new InternalizationQueueReadModel(stateManager);
124
+ readModel.setPolicy({
125
+ enabledChannels: new Set(['prompt', 'code_tool_hook', 'defer_archive']),
126
+ actionableTaskKinds: new Set(MVP_CORE_TASK_KINDS),
127
+ });
128
+ const snapshot = await readModel.getSnapshot();
129
+
130
+ if (snapshot.readyTasks.length > 5) {
131
+ logger.warn(`[PD:AutoConsumer] Backlog detected: ${snapshot.readyTasks.length} tasks ready. Processing only one task.`);
132
+ }
133
+
134
+ const decision = computeConsumerDecision({
135
+ autoConsumerEnabled: true,
136
+ readyTaskCount: snapshot.readyTasks.length,
137
+ });
138
+
139
+ if (!decision.shouldConsume) {
140
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_SKIP', JSON.stringify({
141
+ reason: decision.reason,
142
+ readyTaskCount: snapshot.readyTasks.length,
143
+ }));
144
+ return;
145
+ }
146
+
147
+ const orchestrator = new InternalizationOrchestrator(
148
+ { stateManager },
149
+ { owner: 'auto-consumer', runtimeKind: 'config', dryRun: true },
150
+ );
151
+
152
+ const wakeResult = await orchestrator.wakeOnce('dreamer');
153
+
154
+ if (wakeResult.decision !== 'would_lease') {
155
+ const skipPayload: Record<string, unknown> = {
156
+ decision: wakeResult.decision,
157
+ };
158
+ if (wakeResult.decision === 'no_ready_tasks') {
159
+ skipPayload.reason = wakeResult.reason;
160
+ }
161
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_SKIP', JSON.stringify(skipPayload));
162
+ logger.info(`[PD:AutoConsumer] No task to consume: ${wakeResult.decision}`);
163
+ return;
164
+ }
165
+
166
+ const adapter = new PiAiRuntimeAdapter({
167
+ provider: runtimeConfigResult.provider ?? 'openai',
168
+ model: runtimeConfigResult.model ?? 'gpt-4o',
169
+ apiKeyEnv: runtimeConfigResult.apiKeyEnv ?? 'OPENAI_API_KEY',
170
+ maxRetries: runtimeConfigResult.maxRetries,
171
+ timeoutMs: runtimeConfigResult.timeoutMs,
172
+ baseUrl: runtimeConfigResult.baseUrl,
173
+ workspace: workspaceDir,
174
+ });
175
+
176
+ const validator = new DefaultDreamerValidator();
177
+ const runner = new DreamerRunner(
178
+ {
179
+ stateManager,
180
+ runtimeAdapter: adapter,
181
+ eventEmitter: storeEmitter,
182
+ artifactStore: stateManager.piArtifactStore,
183
+ validator,
184
+ },
185
+ {
186
+ owner: 'auto-consumer',
187
+ runtimeKind: 'config',
188
+ },
189
+ );
190
+
191
+ const taskId = wakeResult.taskId;
192
+ logger.info(`[PD:AutoConsumer] Running dreamer task: ${taskId}`);
193
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_RUN', JSON.stringify({
194
+ taskId,
195
+ taskKind: 'dreamer',
196
+ }));
197
+
198
+ const runResult = await runner.run(taskId);
199
+
200
+ if (runResult.status === 'succeeded') {
201
+ const commitResult = await orchestrator.commitNextTaskProposal(taskId);
202
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_SUCCESS', JSON.stringify({
203
+ taskId,
204
+ status: runResult.status,
205
+ successorDecision: commitResult.decision,
206
+ }));
207
+ logger.info(
208
+ `[PD:AutoConsumer] Task ${taskId} succeeded. Successor: ${commitResult.decision}`,
209
+ );
210
+ } else {
211
+ const errorCategory = runResult.errorCategory;
212
+ const failureReason = runResult.failureReason;
213
+ const nextAction = getNextActionForError(errorCategory);
214
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_TASK_FAILED', JSON.stringify({
215
+ taskId,
216
+ status: runResult.status,
217
+ errorCategory,
218
+ failureReason,
219
+ nextAction,
220
+ }));
221
+ logger.warn(
222
+ `[PD:AutoConsumer] Task ${taskId} status: ${runResult.status}. Category: ${errorCategory}. Reason: ${failureReason}. Next Action: ${nextAction}`
223
+ );
224
+ }
225
+ } catch (err) {
226
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_ERROR', String(err));
227
+ logger.error(`[PD:AutoConsumer] Cycle error: ${String(err)}`);
228
+ } finally {
229
+ if (handle) {
230
+ await handle.close().catch(() => {});
231
+ }
232
+ }
233
+ }
234
+
235
+ export const InternalizationAutoConsumerService: InternalizationAutoConsumerServiceShape = {
236
+ id: 'principles-internalization-auto-consumer',
237
+
238
+ start(ctx: OpenClawPluginServiceContext): void {
239
+ const maybeWorkspaceDir = ctx?.workspaceDir;
240
+ const logger = ctx?.logger || console;
241
+
242
+ if (!maybeWorkspaceDir) {
243
+ logger.warn('[PD:AutoConsumer] No workspace directory, not starting.');
244
+ return;
245
+ }
246
+
247
+ const workspaceDir: string = maybeWorkspaceDir;
248
+ const state = getWorkspaceState(workspaceDir);
249
+
250
+ if (!state.stopped && state.timeoutId !== null) {
251
+ logger.info(`[PD:AutoConsumer] Already started for workspace: ${workspaceDir}`);
252
+ return;
253
+ }
254
+
255
+ const flag = loadFeatureFlagFromConfig(workspaceDir, INTERNALIZATION_AUTO_CONSUMER_FLAG_ID, {
256
+ info: (msg: string) => logger.info(msg),
257
+ warn: (msg: string) => logger.warn(msg),
258
+ });
259
+
260
+ if (!flag.enabled) {
261
+ const disabledInfo = JSON.stringify({
262
+ reason: 'internalization_auto_consumer_disabled',
263
+ nextAction: formatRunOnceCommand(workspaceDir),
264
+ flagSource: flag.source,
265
+ });
266
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_DISABLED', disabledInfo);
267
+ logger.info(
268
+ `[PD:AutoConsumer] NOT started for workspace: ${workspaceDir}. Disabled (source: ${flag.source}).`,
269
+ );
270
+ return;
271
+ }
272
+
273
+ state.stopped = false;
274
+
275
+ const interval = INTERNALIZATION_AUTO_CONSUMER_INTERVAL_MS;
276
+
277
+ function scheduleNext(): void {
278
+ if (state.stopped) return;
279
+ state.timeoutId = setTimeout(runCycle, interval);
280
+ state.timeoutId?.unref();
281
+ }
282
+
283
+ async function runCycle(): Promise<void> {
284
+ if (state.stopped) return;
285
+ await runConsumerCycle(workspaceDir, logger);
286
+ scheduleNext();
287
+ }
288
+
289
+ state.timeoutId = setTimeout(() => {
290
+ void runCycle().catch((err: unknown) => {
291
+ logger.error(`[PD:AutoConsumer] Startup cycle failed: ${String(err)}`);
292
+ if (state.stopped) return;
293
+ state.timeoutId = setTimeout(runCycle, interval);
294
+ state.timeoutId?.unref();
295
+ });
296
+ }, INTERNALIZATION_AUTO_CONSUMER_INITIAL_DELAY_MS);
297
+ state.timeoutId?.unref();
298
+
299
+ SystemLogger.log(workspaceDir, 'INTERNALIZATION_CONSUMER_STARTED', JSON.stringify({
300
+ intervalMs: interval,
301
+ initialDelayMs: INTERNALIZATION_AUTO_CONSUMER_INITIAL_DELAY_MS,
302
+ }));
303
+ logger.info(
304
+ `[PD:AutoConsumer] Started for workspace: ${workspaceDir} (interval: ${interval}ms, initial delay: ${INTERNALIZATION_AUTO_CONSUMER_INITIAL_DELAY_MS}ms)`,
305
+ );
306
+ },
307
+
308
+ stop(ctx: OpenClawPluginServiceContext): void {
309
+ const workspaceDir = ctx?.workspaceDir;
310
+ if (workspaceDir) {
311
+ const state = workspaceStates.get(workspaceDir);
312
+ if (state) {
313
+ state.stopped = true;
314
+ if (state.timeoutId) clearTimeout(state.timeoutId);
315
+ state.timeoutId = null;
316
+ }
317
+ workspaceStates.delete(workspaceDir);
318
+ } else {
319
+ for (const [, state] of workspaceStates) {
320
+ state.stopped = true;
321
+ if (state.timeoutId) clearTimeout(state.timeoutId);
322
+ state.timeoutId = null;
323
+ }
324
+ workspaceStates.clear();
325
+ }
326
+ },
327
+ };
@@ -174,6 +174,8 @@ describe('PRI-294: Surface registry coverage audit', () => {
174
174
  'startup:workspace-init',
175
175
  'startup:evolution-worker',
176
176
  'startup:correction-observer',
177
+ 'service:internalization-auto-consumer', // PRI-381: bounded auto-consumer
178
+ 'startup:internalization-auto-consumer', // PRI-381: auto-consumer startup
177
179
  ];
178
180
  const allowedIds = new Set([...usedSet, ...additionallyRegistered]);
179
181
  const unaccounted = PLUGIN_SURFACE_REGISTRY
@@ -244,6 +244,7 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
244
244
  const expectedIds = [
245
245
  'service:central-sync',
246
246
  'service:correction-observer',
247
+ 'service:internalization-auto-consumer',
247
248
  'service:pd-task',
248
249
  'service:trajectory',
249
250
  ].sort();
@@ -0,0 +1,158 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import * as yaml from 'js-yaml';
6
+
7
+ vi.mock('../src/core/dictionary-service.js', () => ({
8
+ DictionaryService: { get: vi.fn(() => ({ flush: vi.fn() })) },
9
+ }));
10
+
11
+ vi.mock('../src/core/session-tracker.js', () => ({
12
+ initPersistence: vi.fn(),
13
+ flushAllSessions: vi.fn(),
14
+ listSessions: vi.fn(() => []),
15
+ }));
16
+
17
+ vi.mock('../src/core/workspace-context.js', () => {
18
+ const mockCtx = {
19
+ stateDir: '',
20
+ workspaceDir: '',
21
+ config: { get: vi.fn() },
22
+ eventLog: { recordHookExecution: vi.fn() },
23
+ dictionary: { flush: vi.fn() },
24
+ resolve: vi.fn((key: string) => `/mock/${key}`),
25
+ trajectory: null,
26
+ };
27
+ return {
28
+ WorkspaceContext: {
29
+ fromHookContext: vi.fn(() => mockCtx),
30
+ clearCache: vi.fn(),
31
+ },
32
+ };
33
+ });
34
+
35
+ import {
36
+ shouldStartInternalizationAutoConsumer,
37
+ loadFeatureFlagFromWorkspace,
38
+ } from '../src/index.js';
39
+
40
+ function createTempWorkspace(): string {
41
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-auto-consumer-'));
42
+ fs.mkdirSync(path.join(dir, '.pd'), { recursive: true });
43
+ fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
44
+ return dir;
45
+ }
46
+
47
+ function writeConfigYaml(workspaceDir: string, featureOverrides: Record<string, unknown>): void {
48
+ const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
49
+ const defaultFeatures: Record<string, unknown> = {
50
+ prompt: { category: 'core', enabled: true },
51
+ code_tool_hook: { category: 'core', enabled: true },
52
+ defer_archive: { category: 'core', enabled: true },
53
+ correction_observer: { category: 'quiet', enabled: false },
54
+ empathy_observer: { category: 'quiet', enabled: false },
55
+ evolution_worker: { category: 'quiet', enabled: false },
56
+ internalization_auto_consumer: { category: 'quiet', enabled: true },
57
+ nocturnal: { category: 'gone', enabled: false },
58
+ };
59
+ const config = {
60
+ version: 1,
61
+ features: Object.assign({}, defaultFeatures, featureOverrides),
62
+ runtimeProfiles: {
63
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
64
+ },
65
+ internalAgents: {
66
+ defaultRuntime: 'openclaw.default',
67
+ agents: {
68
+ diagnostician: { enabled: true },
69
+ dreamer: { enabled: true },
70
+ scribe: { enabled: true },
71
+ artificer: { enabled: true },
72
+ philosopher: { enabled: false },
73
+ evaluator: { enabled: false },
74
+ rolloutReviewer: { enabled: false },
75
+ trainer: { enabled: false },
76
+ correctionObserver: { enabled: false },
77
+ empathyObserver: { enabled: false },
78
+ },
79
+ },
80
+ };
81
+ const content = yaml.dump(config, { schema: yaml.JSON_SCHEMA });
82
+ fs.writeFileSync(configPath, content, 'utf8');
83
+ }
84
+
85
+ function createMockLogger() {
86
+ return {
87
+ info: vi.fn(),
88
+ warn: vi.fn(),
89
+ error: vi.fn(),
90
+ debug: vi.fn(),
91
+ };
92
+ }
93
+
94
+ describe('PRI-381: InternalizationAutoConsumer gate', () => {
95
+ let workspaceDir: string;
96
+
97
+ beforeEach(() => {
98
+ workspaceDir = createTempWorkspace();
99
+ });
100
+
101
+ afterEach(() => {
102
+ try {
103
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
104
+ } catch { /* best-effort */ }
105
+ });
106
+
107
+ describe('shouldStartInternalizationAutoConsumer', () => {
108
+ it('returns shouldStart=true with defaults (quiet flag — default enabled)', () => {
109
+ const logger = createMockLogger();
110
+ const result = shouldStartInternalizationAutoConsumer(workspaceDir, logger);
111
+ expect(result.shouldStart).toBe(true);
112
+ expect(result.flagSource).toBeDefined();
113
+ expect(result.disabledInfo).toBeNull();
114
+ });
115
+
116
+ it('returns shouldStart=false when config disables the flag, with reason and nextAction', () => {
117
+ writeConfigYaml(workspaceDir, {
118
+ internalization_auto_consumer: { category: 'quiet', enabled: false },
119
+ });
120
+ const logger = createMockLogger();
121
+ const result = shouldStartInternalizationAutoConsumer(workspaceDir, logger);
122
+
123
+ expect(result.shouldStart).toBe(false);
124
+ expect(result.disabledInfo).not.toBeNull();
125
+ const info = JSON.parse(result.disabledInfo ?? '{}');
126
+ expect(info.reason).toBe('internalization_auto_consumer_disabled');
127
+ expect(info.nextAction).toContain('pd runtime internalization run-once');
128
+ expect(info.flagSource).toBeDefined();
129
+ });
130
+
131
+ it('returns shouldStart=true with explicit config enabling', () => {
132
+ writeConfigYaml(workspaceDir, {
133
+ internalization_auto_consumer: { category: 'quiet', enabled: true },
134
+ });
135
+ const logger = createMockLogger();
136
+ const result = shouldStartInternalizationAutoConsumer(workspaceDir, logger);
137
+ expect(result.shouldStart).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe('loadFeatureFlagFromWorkspace for auto-consumer', () => {
142
+ it('returns enabled=true when no config.yaml exists (default)', () => {
143
+ const logger = createMockLogger();
144
+ const result = loadFeatureFlagFromWorkspace(workspaceDir, 'internalization_auto_consumer', logger);
145
+ expect(result.enabled).toBe(true);
146
+ expect(result.source).toBe('defaults');
147
+ });
148
+
149
+ it('returns enabled=false when config disables the flag (quiet flag can be disabled)', () => {
150
+ writeConfigYaml(workspaceDir, {
151
+ internalization_auto_consumer: { category: 'quiet', enabled: false },
152
+ });
153
+ const logger = createMockLogger();
154
+ const result = loadFeatureFlagFromWorkspace(workspaceDir, 'internalization_auto_consumer', logger);
155
+ expect(result.enabled).toBe(false);
156
+ });
157
+ });
158
+ });