principles-disciple 1.75.0 → 1.76.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/index.ts +114 -10
- package/tests/evolution-worker-quarantine.test.ts +342 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ import type {
|
|
|
18
18
|
PluginHookSubagentContext,
|
|
19
19
|
} from './openclaw-sdk.js';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as yaml from 'js-yaml';
|
|
23
|
+
import { computeEffectiveFlags, DEFAULT_FEATURE_FLAGS } from '@principles/core/runtime-v2';
|
|
21
24
|
import { classifyTask } from './core/local-worker-routing.js';
|
|
22
25
|
import { completeShadowObservation, recordShadowRouting } from './core/shadow-observation-registry.js';
|
|
23
26
|
import { getCommandDescription } from './i18n/commands.js';
|
|
@@ -71,6 +74,99 @@ const HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION =
|
|
|
71
74
|
// Used to complete shadow observations when subagent ends
|
|
72
75
|
const pendingShadowObservations = new Map<string, string>();
|
|
73
76
|
|
|
77
|
+
// ── Feature Flag Loader (plugin I/O boundary) ─────────────────────────────
|
|
78
|
+
// Reads workspace feature-flags.yaml and checks a specific flag.
|
|
79
|
+
// Returns the flag definition with effective enabled state.
|
|
80
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
81
|
+
|
|
82
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadFeatureFlagFromWorkspace(
|
|
87
|
+
workspaceDir: string,
|
|
88
|
+
flagId: string,
|
|
89
|
+
logger?: { warn?: (msg: string) => void; info?: (msg: string) => void },
|
|
90
|
+
): { enabled: boolean; source: string } {
|
|
91
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(configPath)) {
|
|
94
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
95
|
+
const flag = flags.flags[flagId];
|
|
96
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let raw: string;
|
|
100
|
+
try {
|
|
101
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
104
|
+
logger?.warn?.(`[PD:FeatureFlags] Feature flags unreadable: ${msg} — using defaults`);
|
|
105
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
106
|
+
const flag = flags.flags[flagId];
|
|
107
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let parsed: unknown;
|
|
111
|
+
try {
|
|
112
|
+
parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const parseMsg = e instanceof Error ? e.message : String(e);
|
|
115
|
+
logger?.warn?.(`[PD:FeatureFlags] Feature flags YAML parse error: ${parseMsg} — using defaults`);
|
|
116
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
117
|
+
const flag = flags.flags[flagId];
|
|
118
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!isRecord(parsed)) {
|
|
122
|
+
logger?.warn?.(`[PD:FeatureFlags] Feature flags not a mapping — using defaults`);
|
|
123
|
+
const flags = computeEffectiveFlags({}, DEFAULT_FEATURE_FLAGS, configPath);
|
|
124
|
+
const flag = flags.flags[flagId];
|
|
125
|
+
return { enabled: flag?.enabled ?? false, source: 'defaults' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// parsed is now narrowed to Record<string, unknown> by isRecord guard
|
|
129
|
+
const parsedRecord: Record<string, unknown> = Object.create(null);
|
|
130
|
+
for (const key of Object.keys(parsed)) {
|
|
131
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
132
|
+
if (Object.hasOwn(parsed, key)) {
|
|
133
|
+
parsedRecord[key] = parsed[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const flags = computeEffectiveFlags(parsedRecord, DEFAULT_FEATURE_FLAGS, configPath);
|
|
138
|
+
const flag = flags.flags[flagId];
|
|
139
|
+
return { enabled: flag?.enabled ?? false, source: flags.source };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Evolution Worker Startup Gate (shared between index.ts and tests) ───────
|
|
143
|
+
// Determines whether the legacy evolution worker should start and produces
|
|
144
|
+
// structured observability when disabled (ERR-002).
|
|
145
|
+
|
|
146
|
+
export interface EvolutionWorkerGateResult {
|
|
147
|
+
shouldStart: boolean;
|
|
148
|
+
flagSource: string;
|
|
149
|
+
disabledInfo: string | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function shouldStartEvolutionWorker(
|
|
153
|
+
workspaceDir: string,
|
|
154
|
+
logger: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
|
155
|
+
): EvolutionWorkerGateResult {
|
|
156
|
+
const flag = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
157
|
+
if (flag.enabled) {
|
|
158
|
+
return { shouldStart: true, flagSource: flag.source, disabledInfo: null };
|
|
159
|
+
}
|
|
160
|
+
const disabledInfo = JSON.stringify({
|
|
161
|
+
reason: 'mvp_quiet_per_adr0014',
|
|
162
|
+
nextAction: 'set evolution_worker.enabled=true in .pd/feature-flags.yaml to enable',
|
|
163
|
+
featureFlag: 'evolution_worker',
|
|
164
|
+
boundedContext: 'legacy_evolution_worker',
|
|
165
|
+
flagSource: flag.source,
|
|
166
|
+
});
|
|
167
|
+
return { shouldStart: false, flagSource: flag.source, disabledInfo };
|
|
168
|
+
}
|
|
169
|
+
|
|
74
170
|
const plugin = {
|
|
75
171
|
name: "Principles Disciple",
|
|
76
172
|
description: "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
@@ -120,16 +216,22 @@ const plugin = {
|
|
|
120
216
|
SystemLogger.log(workspaceDir, 'SYSTEM_BOOT', `Principles Disciple online. Language: ${language}`);
|
|
121
217
|
|
|
122
218
|
// ── Start EvolutionWorker for THIS workspace ──
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
219
|
+
// Gated behind evolution_worker feature flag (MVP-Quiet, default OFF per ADR-0014).
|
|
220
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, api.logger);
|
|
221
|
+
if (gate.shouldStart) {
|
|
222
|
+
EvolutionWorkerService.api = api;
|
|
223
|
+
EvolutionWorkerService.start({
|
|
224
|
+
config: api.config,
|
|
225
|
+
workspaceDir,
|
|
226
|
+
stateDir: path.join(workspaceDir, '.state'),
|
|
227
|
+
logger: api.logger,
|
|
228
|
+
});
|
|
229
|
+
api.logger.info(`[PD] EvolutionWorker started for workspace: ${workspaceDir} (flag source: ${gate.flagSource})`);
|
|
230
|
+
} else {
|
|
231
|
+
// Structured observability per ERR-002: no silent skip
|
|
232
|
+
api.logger.info(`[PD] EvolutionWorker NOT started for workspace: ${workspaceDir}. ${gate.disabledInfo}`);
|
|
233
|
+
SystemLogger.log(workspaceDir, 'EVOLUTION_WORKER_DISABLED', gate.disabledInfo ?? '');
|
|
234
|
+
}
|
|
133
235
|
}
|
|
134
236
|
|
|
135
237
|
const result = await handleBeforePromptBuild(event, { ...ctx, api: api as Parameters<typeof handleBeforePromptBuild>[1]['api'], workspaceDir });
|
|
@@ -758,5 +860,7 @@ const plugin = {
|
|
|
758
860
|
};
|
|
759
861
|
|
|
760
862
|
export { PrincipleTreeLedgerAdapter } from './core/principle-tree-ledger-adapter.js';
|
|
863
|
+
/* istanbul ignore next — test exports for evolution worker gate */
|
|
864
|
+
export { loadFeatureFlagFromWorkspace, isRecord };
|
|
761
865
|
|
|
762
866
|
export default plugin;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-288: Quarantine EvolutionWorkerService default startup behind MVP feature flag.
|
|
3
|
+
*
|
|
4
|
+
* Tests prove:
|
|
5
|
+
* 1. Default config (no feature-flags.yaml) → EvolutionWorkerService does NOT start.
|
|
6
|
+
* 2. Explicit enable in feature-flags.yaml → EvolutionWorkerService starts.
|
|
7
|
+
* 3. Disabled state has structured observability from real helper, not hand-written JSON.
|
|
8
|
+
* 4. api.registerService still works regardless of flag state.
|
|
9
|
+
*
|
|
10
|
+
* ERR-002: disabled startup must be observable — verified via real shouldStartEvolutionWorker output.
|
|
11
|
+
* ERR-025: tests cover the real gate helper + loadFeatureFlagFromWorkspace, not hand-coded JSON.
|
|
12
|
+
* ERR-027: DEFAULT_FEATURE_FLAGS declaration matches runtime behavior.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as yaml from 'js-yaml';
|
|
19
|
+
|
|
20
|
+
// ── Mock dependencies that would trigger side effects ──
|
|
21
|
+
vi.mock('../src/core/dictionary-service.js', () => ({
|
|
22
|
+
DictionaryService: { get: vi.fn(() => ({ flush: vi.fn() })) },
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('../src/core/session-tracker.js', () => ({
|
|
26
|
+
initPersistence: vi.fn(),
|
|
27
|
+
flushAllSessions: vi.fn(),
|
|
28
|
+
listSessions: vi.fn(() => []),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../src/core/workspace-context.js', () => {
|
|
32
|
+
const mockCtx = {
|
|
33
|
+
stateDir: '',
|
|
34
|
+
workspaceDir: '',
|
|
35
|
+
config: { get: vi.fn() },
|
|
36
|
+
eventLog: { recordHookExecution: vi.fn() },
|
|
37
|
+
dictionary: { flush: vi.fn() },
|
|
38
|
+
resolve: vi.fn((key: string) => `/mock/${key}`),
|
|
39
|
+
trajectory: null,
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
WorkspaceContext: {
|
|
43
|
+
fromHookContext: vi.fn(() => mockCtx),
|
|
44
|
+
clearCache: vi.fn(),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Import after mocks — real helpers, not re-implemented logic
|
|
50
|
+
import { loadFeatureFlagFromWorkspace, shouldStartEvolutionWorker, isRecord } from '../src/index.js';
|
|
51
|
+
import { EvolutionWorkerService } from '../src/service/evolution-worker.js';
|
|
52
|
+
import { computeEffectiveFlags, DEFAULT_FEATURE_FLAGS } from '@principles/core/runtime-v2';
|
|
53
|
+
|
|
54
|
+
// ── Helpers ──
|
|
55
|
+
|
|
56
|
+
function createTempWorkspace(): string {
|
|
57
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-quarantine-'));
|
|
58
|
+
fs.mkdirSync(path.join(dir, '.pd'), { recursive: true });
|
|
59
|
+
fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeFeatureFlags(workspaceDir: string, flags: Record<string, unknown>): void {
|
|
64
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
65
|
+
const content = yaml.dump(flags, { schema: yaml.JSON_SCHEMA });
|
|
66
|
+
fs.writeFileSync(configPath, content, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMockLogger() {
|
|
70
|
+
return {
|
|
71
|
+
info: vi.fn(),
|
|
72
|
+
warn: vi.fn(),
|
|
73
|
+
error: vi.fn(),
|
|
74
|
+
debug: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Tests ──
|
|
79
|
+
|
|
80
|
+
describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
81
|
+
let workspaceDir: string;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
workspaceDir = createTempWorkspace();
|
|
85
|
+
EvolutionWorkerService.api = null;
|
|
86
|
+
EvolutionWorkerService._startedWorkspaces.clear();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
EvolutionWorkerService.api = null;
|
|
91
|
+
EvolutionWorkerService._startedWorkspaces.clear();
|
|
92
|
+
// Clean up temp dir
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
|
95
|
+
} catch {
|
|
96
|
+
// best-effort
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── 1. Feature flag registry ──
|
|
101
|
+
|
|
102
|
+
describe('DEFAULT_FEATURE_FLAGS includes evolution_worker', () => {
|
|
103
|
+
it('has evolution_worker flag with quiet category and enabled=false', () => {
|
|
104
|
+
const flag = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'evolution_worker');
|
|
105
|
+
expect(flag).toBeDefined();
|
|
106
|
+
expect(flag!.category).toBe('quiet');
|
|
107
|
+
expect(flag!.enabled).toBe(false);
|
|
108
|
+
expect(flag!.since).toBe('2026-06-01');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── 2. loadFeatureFlagFromWorkspace ──
|
|
113
|
+
|
|
114
|
+
describe('loadFeatureFlagFromWorkspace', () => {
|
|
115
|
+
it('returns enabled=false when no feature-flags.yaml exists', () => {
|
|
116
|
+
const logger = createMockLogger();
|
|
117
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
118
|
+
expect(result.enabled).toBe(false);
|
|
119
|
+
expect(result.source).toBe('defaults');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns enabled=false when feature-flags.yaml has no evolution_worker entry', () => {
|
|
123
|
+
writeFeatureFlags(workspaceDir, { prompt: { enabled: true } });
|
|
124
|
+
const logger = createMockLogger();
|
|
125
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
126
|
+
expect(result.enabled).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns enabled=true when feature-flags.yaml explicitly enables evolution_worker', () => {
|
|
130
|
+
writeFeatureFlags(workspaceDir, {
|
|
131
|
+
evolution_worker: { enabled: true },
|
|
132
|
+
});
|
|
133
|
+
const logger = createMockLogger();
|
|
134
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
135
|
+
expect(result.enabled).toBe(true);
|
|
136
|
+
expect(result.source).toBe('workspace_file');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns enabled=false when YAML is malformed and warning includes error detail', () => {
|
|
140
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
141
|
+
fs.writeFileSync(configPath, ' bad: [yaml: content', 'utf8');
|
|
142
|
+
const logger = createMockLogger();
|
|
143
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
144
|
+
expect(result.enabled).toBe(false);
|
|
145
|
+
// YAML parse warning must include error detail (fix #6)
|
|
146
|
+
const warnCalls = logger.warn.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
147
|
+
const parseWarn = warnCalls.find((m: string) => m.includes('YAML parse error'));
|
|
148
|
+
expect(parseWarn).toBeDefined();
|
|
149
|
+
// Error detail must contain something beyond "using defaults"
|
|
150
|
+
expect(parseWarn!.length).toBeGreaterThan('YAML parse error — using defaults'.length);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns defaults when file is unreadable', () => {
|
|
154
|
+
// Create a directory where a file should be — causes read error
|
|
155
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
156
|
+
fs.mkdirSync(configPath, { recursive: true });
|
|
157
|
+
const logger = createMockLogger();
|
|
158
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
159
|
+
expect(result.enabled).toBe(false);
|
|
160
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects dangerous keys (__proto__) and does not enable via prototype pollution', () => {
|
|
164
|
+
// Write raw YAML with __proto__ to test dangerous key rejection on raw parsed output
|
|
165
|
+
writeFeatureFlags(workspaceDir, {
|
|
166
|
+
__proto__: { enabled: true },
|
|
167
|
+
evolution_worker: { enabled: false },
|
|
168
|
+
});
|
|
169
|
+
const logger = createMockLogger();
|
|
170
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
171
|
+
expect(result.enabled).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── 3. shouldStartEvolutionWorker — real helper, real output ──
|
|
176
|
+
|
|
177
|
+
describe('shouldStartEvolutionWorker gate helper', () => {
|
|
178
|
+
it('returns shouldStart=false by default (no feature-flags.yaml)', () => {
|
|
179
|
+
const logger = createMockLogger();
|
|
180
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
181
|
+
expect(gate.shouldStart).toBe(false);
|
|
182
|
+
expect(gate.flagSource).toBe('defaults');
|
|
183
|
+
expect(gate.disabledInfo).not.toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns shouldStart=true when explicitly enabled', () => {
|
|
187
|
+
writeFeatureFlags(workspaceDir, {
|
|
188
|
+
evolution_worker: { enabled: true },
|
|
189
|
+
});
|
|
190
|
+
const logger = createMockLogger();
|
|
191
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
192
|
+
expect(gate.shouldStart).toBe(true);
|
|
193
|
+
expect(gate.flagSource).toBe('workspace_file');
|
|
194
|
+
expect(gate.disabledInfo).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('disabledInfo is valid JSON with required structured fields', () => {
|
|
198
|
+
const logger = createMockLogger();
|
|
199
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
200
|
+
expect(gate.disabledInfo).not.toBeNull();
|
|
201
|
+
// Parse the real output — not hand-written JSON
|
|
202
|
+
const parsed = JSON.parse(gate.disabledInfo!);
|
|
203
|
+
expect(parsed.reason).toBe('mvp_quiet_per_adr0014');
|
|
204
|
+
expect(parsed.nextAction).toContain('evolution_worker.enabled=true');
|
|
205
|
+
expect(parsed.featureFlag).toBe('evolution_worker');
|
|
206
|
+
expect(parsed.boundedContext).toBe('legacy_evolution_worker');
|
|
207
|
+
expect(parsed.flagSource).toBe('defaults');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── 4. EvolutionWorkerService does NOT start by default ──
|
|
212
|
+
|
|
213
|
+
describe('default config: worker does not start', () => {
|
|
214
|
+
it('EvolutionWorkerService.start is NOT called when gate returns shouldStart=false', () => {
|
|
215
|
+
const startSpy = vi.spyOn(EvolutionWorkerService, 'start');
|
|
216
|
+
const logger = createMockLogger();
|
|
217
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
218
|
+
|
|
219
|
+
expect(gate.shouldStart).toBe(false);
|
|
220
|
+
expect(EvolutionWorkerService._startedWorkspaces.has(workspaceDir)).toBe(false);
|
|
221
|
+
expect(startSpy).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
startSpy.mockRestore();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('disabled observability comes from real helper — logger receives structured output', () => {
|
|
227
|
+
const logger = createMockLogger();
|
|
228
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
229
|
+
|
|
230
|
+
// Simulate what index.ts does with the gate result
|
|
231
|
+
if (!gate.shouldStart && gate.disabledInfo) {
|
|
232
|
+
logger.info(`[PD] EvolutionWorker NOT started for workspace: ${workspaceDir}. ${gate.disabledInfo}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
expect(logger.info).toHaveBeenCalledTimes(1);
|
|
236
|
+
const loggedMsg = logger.info.mock.calls[0][0] as string;
|
|
237
|
+
expect(loggedMsg).toContain('EvolutionWorker NOT started');
|
|
238
|
+
// Parse the JSON from the real helper output embedded in the log message
|
|
239
|
+
const jsonStart = loggedMsg.indexOf('{');
|
|
240
|
+
expect(jsonStart).toBeGreaterThan(0);
|
|
241
|
+
const parsed = JSON.parse(loggedMsg.substring(jsonStart));
|
|
242
|
+
expect(parsed.reason).toBe('mvp_quiet_per_adr0014');
|
|
243
|
+
expect(parsed.featureFlag).toBe('evolution_worker');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── 5. Explicit enable: worker starts ──
|
|
248
|
+
|
|
249
|
+
describe('explicit enable: worker starts', () => {
|
|
250
|
+
it('shouldStartEvolutionWorker returns true when enabled in config', () => {
|
|
251
|
+
writeFeatureFlags(workspaceDir, {
|
|
252
|
+
evolution_worker: { enabled: true },
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const logger = createMockLogger();
|
|
256
|
+
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
257
|
+
expect(gate.shouldStart).toBe(true);
|
|
258
|
+
expect(gate.flagSource).toBe('workspace_file');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('EvolutionWorkerService.start actually runs when gate is true', () => {
|
|
262
|
+
writeFeatureFlags(workspaceDir, {
|
|
263
|
+
evolution_worker: { enabled: true },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const mockLogger = createMockLogger();
|
|
267
|
+
const mockConfig = { get: (k: string) => k === 'intervals.worker_poll_ms' ? 60000 : undefined };
|
|
268
|
+
const mockApi = {
|
|
269
|
+
logger: mockLogger,
|
|
270
|
+
config: mockConfig,
|
|
271
|
+
runtime: { subagent: {} },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
EvolutionWorkerService.api = mockApi as typeof EvolutionWorkerService.api;
|
|
275
|
+
EvolutionWorkerService.start({
|
|
276
|
+
config: mockConfig,
|
|
277
|
+
workspaceDir,
|
|
278
|
+
stateDir: path.join(workspaceDir, '.state'),
|
|
279
|
+
logger: mockLogger,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(EvolutionWorkerService._startedWorkspaces.has(workspaceDir)).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── 6. Core flags remain functional ──
|
|
287
|
+
|
|
288
|
+
describe('MVP-Core flags unaffected', () => {
|
|
289
|
+
it('prompt, code_tool_hook, defer_archive remain core+enabled', () => {
|
|
290
|
+
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'prompt', createMockLogger());
|
|
291
|
+
expect(result.enabled).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('computeEffectiveFlags preserves core flags even with evolution_worker override', () => {
|
|
295
|
+
writeFeatureFlags(workspaceDir, {
|
|
296
|
+
evolution_worker: { enabled: true },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
300
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
301
|
+
const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
302
|
+
|
|
303
|
+
// Use isRecord type guard instead of `as`
|
|
304
|
+
expect(isRecord(parsed)).toBe(true);
|
|
305
|
+
const flags = computeEffectiveFlags(parsed as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
|
|
306
|
+
expect(flags.flags['prompt']?.enabled).toBe(true);
|
|
307
|
+
expect(flags.flags['code_tool_hook']?.enabled).toBe(true);
|
|
308
|
+
expect(flags.flags['defer_archive']?.enabled).toBe(true);
|
|
309
|
+
expect(flags.flags['evolution_worker']?.enabled).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('core flags cannot be disabled by user override', () => {
|
|
313
|
+
writeFeatureFlags(workspaceDir, {
|
|
314
|
+
prompt: { enabled: false },
|
|
315
|
+
code_tool_hook: { enabled: false },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const configPath = path.join(workspaceDir, '.pd', 'feature-flags.yaml');
|
|
319
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
320
|
+
const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
321
|
+
|
|
322
|
+
expect(isRecord(parsed)).toBe(true);
|
|
323
|
+
const flags = computeEffectiveFlags(parsed as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
|
|
324
|
+
expect(flags.flags['prompt']?.enabled).toBe(true); // core cannot be disabled
|
|
325
|
+
expect(flags.flags['code_tool_hook']?.enabled).toBe(true); // core cannot be disabled
|
|
326
|
+
expect(flags.warnings.length).toBeGreaterThan(0); // warnings about core override attempt
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── 7. No PLAN.md / confirm-first gate regression ──
|
|
331
|
+
|
|
332
|
+
describe('no confirm-first gate regression', () => {
|
|
333
|
+
it('no PLAN.md or confirm-first files are created in workspace', () => {
|
|
334
|
+
writeFeatureFlags(workspaceDir, {
|
|
335
|
+
evolution_worker: { enabled: false },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const planMd = path.join(workspaceDir, 'PLAN.md');
|
|
339
|
+
expect(fs.existsSync(planMd)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|