leerness 1.36.183 → 1.36.185
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/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/README.ko.md +15 -8
- package/README.md +25 -7
- package/SECURITY.md +66 -66
- package/bin/leerness.js +1184 -206
- package/docs/role-agent-routing-v2.md +411 -0
- package/lib/agents.js +1236 -100
- package/lib/diagnostics.js +184 -184
- package/lib/file-leases.js +845 -0
- package/lib/health.js +350 -350
- package/lib/io.js +152 -1
- package/lib/mcp-tools.js +121 -104
- package/lib/pure-utils.js +52 -4
- package/lib/referee.js +270 -270
- package/lib/review-request.js +339 -339
- package/lib/role-agent-schema.js +1179 -0
- package/lib/role-catalog.js +31 -1
- package/lib/role-fallback.js +1611 -0
- package/lib/role-store.js +730 -0
- package/lib/routing.js +109 -21
- package/lib/session-close.js +49 -1
- package/package.json +10 -5
- package/scripts/command-flags-probe.js +40 -3
- package/scripts/e2e.js +121 -44
- package/scripts/file-lease-probe.js +1139 -0
- package/scripts/i18n-next-cluster-probe.js +436 -5
- package/scripts/installed-cleanroom-probe.js +39 -12
- package/scripts/lint.js +73 -73
- package/scripts/provider-capacity-probe.js +425 -0
- package/scripts/release-runtime-probe.js +16 -1
- package/scripts/role-agent-schema-probe.js +834 -0
- package/scripts/role-fallback-probe.js +2010 -0
- package/scripts/role-store-loader-probe.js +882 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const childProcess = require('child_process');
|
|
8
|
+
|
|
9
|
+
const MODULE = path.resolve(__dirname, '..', 'lib', 'role-agent-schema.js');
|
|
10
|
+
const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
|
|
11
|
+
|
|
12
|
+
function stableEnv() {
|
|
13
|
+
return JSON.stringify(Object.keys(process.env).sort().map(key => [key, process.env[key]]));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let writeCalls = 0;
|
|
17
|
+
let spawnCalls = 0;
|
|
18
|
+
let clockCalls = 0;
|
|
19
|
+
const originalDateNow = Date.now;
|
|
20
|
+
Date.now = function observedDateNow() { clockCalls++; return originalDateNow(); };
|
|
21
|
+
const patched = [];
|
|
22
|
+
for (const [object, key] of [
|
|
23
|
+
[fs, 'writeFileSync'], [fs, 'appendFileSync'], [fs, 'mkdirSync'], [fs, 'renameSync'], [fs, 'rmSync'],
|
|
24
|
+
[childProcess, 'spawn'], [childProcess, 'spawnSync'], [childProcess, 'execFile'], [childProcess, 'execFileSync'],
|
|
25
|
+
]) {
|
|
26
|
+
const original = object[key];
|
|
27
|
+
patched.push(() => { object[key] = original; });
|
|
28
|
+
object[key] = function patchedSideEffect(...args) {
|
|
29
|
+
if (object === childProcess) spawnCalls++;
|
|
30
|
+
else writeCalls++;
|
|
31
|
+
return original.apply(this, args);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const envBeforeRequire = stableEnv();
|
|
35
|
+
delete require.cache[MODULE];
|
|
36
|
+
const schema = require(MODULE);
|
|
37
|
+
const envAfterRequire = stableEnv();
|
|
38
|
+
for (const restore of patched.reverse()) restore();
|
|
39
|
+
Date.now = originalDateNow;
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
SCHEMA_VERSION,
|
|
43
|
+
STORE_FILES,
|
|
44
|
+
PERMISSION_TIERS,
|
|
45
|
+
BUILTIN_ROLE_IDS,
|
|
46
|
+
LEGACY_PERSISTED_ROLE_IDS,
|
|
47
|
+
canonicalRoleId,
|
|
48
|
+
stableStringify,
|
|
49
|
+
legacyRoleId,
|
|
50
|
+
stableLegacyAgentId,
|
|
51
|
+
validateRoleDefinitions,
|
|
52
|
+
validateAgentInstances,
|
|
53
|
+
validateRoutingPolicy,
|
|
54
|
+
validateLegacyRoleStore,
|
|
55
|
+
parseLegacyRoleStoreText,
|
|
56
|
+
legacySemanticAssignments,
|
|
57
|
+
defaultRoutingPolicyDocument,
|
|
58
|
+
projectLegacyRoleStore,
|
|
59
|
+
projectAgentInstancesToLegacy,
|
|
60
|
+
validateConfigBundle,
|
|
61
|
+
} = schema;
|
|
62
|
+
|
|
63
|
+
const failures = [];
|
|
64
|
+
let total = 0;
|
|
65
|
+
function check(label, condition, detail) {
|
|
66
|
+
total++;
|
|
67
|
+
if (condition) {
|
|
68
|
+
console.log(`✓ ${label}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
failures.push(label);
|
|
72
|
+
console.log(`✗ ${label}${detail ? `\n ${detail}` : ''}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clone(value) {
|
|
76
|
+
return JSON.parse(JSON.stringify(value));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasProblem(result, code, detail) {
|
|
80
|
+
return !!result && Array.isArray(result.problems) && result.problems.some(problem => problem.code === code && (detail === undefined || String(problem.detail || '').includes(detail)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const legacy = {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
updatedAt: '2026-09-03T00:00:00.000Z',
|
|
86
|
+
projectNote: { owner: 'local' },
|
|
87
|
+
roles: {
|
|
88
|
+
coder: {
|
|
89
|
+
provider: 'codex',
|
|
90
|
+
model: 'gpt-5-codex',
|
|
91
|
+
persona: 'bounded implementer',
|
|
92
|
+
customEntry: { keep: true },
|
|
93
|
+
},
|
|
94
|
+
reviewer: {
|
|
95
|
+
provider: 'claude',
|
|
96
|
+
model: null,
|
|
97
|
+
persona: 'independent reviewer',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
check('module require performs no file writes, process launches, or environment mutation',
|
|
103
|
+
writeCalls === 0 && spawnCalls === 0 && clockCalls === 0 && envBeforeRequire === envAfterRequire,
|
|
104
|
+
`writes=${writeCalls} spawns=${spawnCalls} clockCalls=${clockCalls} envChanged=${envBeforeRequire !== envAfterRequire}`);
|
|
105
|
+
|
|
106
|
+
check('v2 store filenames are explicit and keep agent-roles.json as compatibility input',
|
|
107
|
+
SCHEMA_VERSION === 2
|
|
108
|
+
&& STORE_FILES.roleDefinitions === 'role-definitions.json'
|
|
109
|
+
&& STORE_FILES.agentInstances === 'agent-instances.json'
|
|
110
|
+
&& STORE_FILES.routingPolicy === 'routing-policy.json'
|
|
111
|
+
&& STORE_FILES.legacyAssignments === 'agent-roles.json');
|
|
112
|
+
const existingPermissionTiers = require('../lib/pure-utils').PERMISSION_TIERS;
|
|
113
|
+
check('schema permission tiers exactly reuse the existing Leerness policy taxonomy',
|
|
114
|
+
stableStringify(PERMISSION_TIERS) === stableStringify(existingPermissionTiers),
|
|
115
|
+
JSON.stringify({ schema: PERMISSION_TIERS, existing: existingPermissionTiers }));
|
|
116
|
+
|
|
117
|
+
const widenedBuiltinRoles = {
|
|
118
|
+
schemaVersion: 2,
|
|
119
|
+
kind: 'role-definitions',
|
|
120
|
+
roles: {
|
|
121
|
+
coder: { requiredTier: 'read-only', approve: true, release: true },
|
|
122
|
+
router: { extends: 'release' },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const widenedBuiltinResult = validateRoleDefinitions(widenedBuiltinRoles);
|
|
126
|
+
check('project overrides cannot widen built-in role permission tiers, authority, or inheritance',
|
|
127
|
+
hasProblem(widenedBuiltinResult, 'builtin-role-tier-widening')
|
|
128
|
+
&& widenedBuiltinResult.problems.filter(problem => problem.code === 'builtin-role-authority-widening').length === 2
|
|
129
|
+
&& hasProblem(widenedBuiltinResult, 'builtin-role-extends-widening', 'release'),
|
|
130
|
+
JSON.stringify(widenedBuiltinResult.problems));
|
|
131
|
+
const narrowedBuiltinRoles = {
|
|
132
|
+
schemaVersion: 2,
|
|
133
|
+
kind: 'role-definitions',
|
|
134
|
+
roles: { coder: { requiredTier: 'publish', codeWrite: false, approve: false, release: false } },
|
|
135
|
+
};
|
|
136
|
+
check('project overrides may explicitly narrow a built-in role ceiling',
|
|
137
|
+
validateRoleDefinitions(narrowedBuiltinRoles).ok,
|
|
138
|
+
JSON.stringify(validateRoleDefinitions(narrowedBuiltinRoles).problems));
|
|
139
|
+
|
|
140
|
+
const designDocText = fs.readFileSync(path.resolve(__dirname, '..', 'docs', 'role-agent-routing-v2.md'), 'utf8');
|
|
141
|
+
const designJsonBlocks = Array.from(designDocText.matchAll(/```json\s*([\s\S]*?)```/g))
|
|
142
|
+
.map(match => { try { return JSON.parse(match[1]); } catch { return null; } })
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
const designRoleDoc = designJsonBlocks.find(doc => doc.kind === 'role-definitions');
|
|
145
|
+
const designAgentDoc = designJsonBlocks.find(doc => doc.kind === 'agent-instances');
|
|
146
|
+
const designRoutingDoc = designJsonBlocks.find(doc => doc.kind === 'routing-policy');
|
|
147
|
+
check('published v2 design JSON examples parse and pass their strict validators',
|
|
148
|
+
designRoleDoc && validateRoleDefinitions(designRoleDoc).ok
|
|
149
|
+
&& designAgentDoc && validateAgentInstances(designAgentDoc).ok
|
|
150
|
+
&& designRoutingDoc && validateRoutingPolicy(designRoutingDoc).ok,
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
role: designRoleDoc && validateRoleDefinitions(designRoleDoc).problems,
|
|
153
|
+
agent: designAgentDoc && validateAgentInstances(designAgentDoc).problems,
|
|
154
|
+
routing: designRoutingDoc && validateRoutingPolicy(designRoutingDoc).problems,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
check('role input aliases normalize to compatibility persisted IDs without changing the catalog IDs',
|
|
158
|
+
canonicalRoleId('orchestrator') === 'commander'
|
|
159
|
+
&& canonicalRoleId('implementer') === 'coder'
|
|
160
|
+
&& canonicalRoleId('worker') === 'coder'
|
|
161
|
+
&& canonicalRoleId('assignment-dispatcher') === 'dispatcher'
|
|
162
|
+
&& BUILTIN_ROLE_IDS.includes('tester')
|
|
163
|
+
&& BUILTIN_ROLE_IDS.includes('security')
|
|
164
|
+
&& stableStringify(LEGACY_PERSISTED_ROLE_IDS) === stableStringify(['commander', 'coder', 'dispatcher', 'reviewer', 'architect', 'designer', 'debugger']));
|
|
165
|
+
|
|
166
|
+
const canonicalSpellingRoleDefinitions = {
|
|
167
|
+
schemaVersion: 2,
|
|
168
|
+
kind: 'role-definitions',
|
|
169
|
+
roles: {
|
|
170
|
+
Coder: {},
|
|
171
|
+
' reviewer ': {},
|
|
172
|
+
'tester': {},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const canonicalSpellingResult = validateRoleDefinitions(canonicalSpellingRoleDefinitions);
|
|
176
|
+
check('persisted v2 role IDs reject case, surrounding whitespace, and full-width variants',
|
|
177
|
+
canonicalSpellingResult.problems.filter(problem => problem.code === 'non-canonical-role-id').length === 3,
|
|
178
|
+
JSON.stringify(canonicalSpellingResult.problems));
|
|
179
|
+
check('prototype-shaped tokens are ordinary inputs rather than inherited alias properties',
|
|
180
|
+
canonicalRoleId('__proto__') === '__proto__'
|
|
181
|
+
&& canonicalRoleId('constructor') === 'constructor'
|
|
182
|
+
&& typeof canonicalRoleId('__proto__') === 'string'
|
|
183
|
+
&& legacyRoleId('__proto__').startsWith('legacy-role-'));
|
|
184
|
+
|
|
185
|
+
const legacyValidation = validateLegacyRoleStore(legacy);
|
|
186
|
+
check('canonical v1 legacy assignment store validates', legacyValidation.ok, JSON.stringify(legacyValidation.problems));
|
|
187
|
+
const priorGeneratedV2Legacy = {
|
|
188
|
+
schemaVersion: 2,
|
|
189
|
+
updatedAt: '2026-09-03T00:00:00.000Z',
|
|
190
|
+
roles: { coder: { provider: 'MyProvider', model: 'custom-model' } },
|
|
191
|
+
};
|
|
192
|
+
check('previous releases schema-v2 legacy output and case-preserving registered providers remain readable',
|
|
193
|
+
validateLegacyRoleStore(priorGeneratedV2Legacy).ok,
|
|
194
|
+
JSON.stringify(validateLegacyRoleStore(priorGeneratedV2Legacy).problems));
|
|
195
|
+
const mixedProviderProjection = projectLegacyRoleStore(priorGeneratedV2Legacy);
|
|
196
|
+
const mixedProviderReverse = mixedProviderProjection.ok
|
|
197
|
+
? projectAgentInstancesToLegacy(mixedProviderProjection.agentInstances, priorGeneratedV2Legacy)
|
|
198
|
+
: null;
|
|
199
|
+
check('case-preserving legacy provider IDs round-trip losslessly through canonical v2 projection',
|
|
200
|
+
mixedProviderReverse && mixedProviderReverse.ok
|
|
201
|
+
&& /^legacy-provider-[0-9a-f]{16}$/.test(mixedProviderProjection.agentInstances.agents[0].provider)
|
|
202
|
+
&& mixedProviderProjection.agentInstances.agents[0].legacyProjection.provider === 'MyProvider'
|
|
203
|
+
&& mixedProviderReverse.document.roles.coder.provider === 'MyProvider',
|
|
204
|
+
JSON.stringify(mixedProviderReverse && mixedProviderReverse.problems || mixedProviderProjection.problems || []));
|
|
205
|
+
const mixedProviderEditedAgents = JSON.parse(JSON.stringify(mixedProviderProjection.agentInstances));
|
|
206
|
+
mixedProviderEditedAgents.agents[0].provider = 'myprovider';
|
|
207
|
+
const mixedProviderEditedReverse = projectAgentInstancesToLegacy(mixedProviderEditedAgents, priorGeneratedV2Legacy);
|
|
208
|
+
check('changing a projected case-preserving provider to its lowercase spelling remains a real edit',
|
|
209
|
+
mixedProviderEditedReverse.ok
|
|
210
|
+
&& mixedProviderEditedReverse.document.roles.coder.provider === 'myprovider',
|
|
211
|
+
JSON.stringify(mixedProviderEditedReverse.problems || []));
|
|
212
|
+
const longLegacyProvider = 'P' + 'x'.repeat(64);
|
|
213
|
+
const longProviderLegacy = { schemaVersion: 1, roles: { coder: { provider: longLegacyProvider } } };
|
|
214
|
+
const longProviderProjection = projectLegacyRoleStore(longProviderLegacy);
|
|
215
|
+
const longProviderReverse = longProviderProjection.ok
|
|
216
|
+
? projectAgentInstancesToLegacy(longProviderProjection.agentInstances, longProviderLegacy)
|
|
217
|
+
: null;
|
|
218
|
+
check('legacy provider IDs longer than native v2 limits use a deterministic lossless compatibility projection',
|
|
219
|
+
validateLegacyRoleStore(longProviderLegacy).ok
|
|
220
|
+
&& longProviderProjection.ok
|
|
221
|
+
&& /^legacy-provider-[0-9a-f]{16}$/.test(longProviderProjection.agentInstances.agents[0].provider)
|
|
222
|
+
&& longProviderReverse && longProviderReverse.ok
|
|
223
|
+
&& longProviderReverse.document.roles.coder.provider === longLegacyProvider,
|
|
224
|
+
JSON.stringify(longProviderProjection.problems || longProviderReverse && longProviderReverse.problems || []));
|
|
225
|
+
check('legacy unknown top-level and per-role fields are surfaced but remain compatible',
|
|
226
|
+
legacyValidation.ok
|
|
227
|
+
&& legacyValidation.unknownFields.includes('$.legacyAssignments.projectNote')
|
|
228
|
+
&& legacyValidation.unknownFields.includes('$.legacyAssignments.roles.coder.customEntry'));
|
|
229
|
+
|
|
230
|
+
const unversioned = { roles: { coder: { provider: 'codex' } }, extension: 'keep' };
|
|
231
|
+
const unversionedValidation = validateLegacyRoleStore(unversioned);
|
|
232
|
+
check('unversioned legacy store remains readable and reports its provenance',
|
|
233
|
+
unversionedValidation.ok && unversionedValidation.unversioned === true && unversionedValidation.version === null);
|
|
234
|
+
check('an existing document missing its roles authority fails closed',
|
|
235
|
+
!validateLegacyRoleStore({ schemaVersion: 1 }).ok
|
|
236
|
+
&& hasProblem(validateLegacyRoleStore({ schemaVersion: 1 }), 'roles-missing'));
|
|
237
|
+
|
|
238
|
+
const projected = projectLegacyRoleStore(legacy);
|
|
239
|
+
check('valid legacy assignments deterministically project into all three v2 documents',
|
|
240
|
+
projected.ok
|
|
241
|
+
&& projected.roleDefinitions.kind === 'role-definitions'
|
|
242
|
+
&& projected.agentInstances.kind === 'agent-instances'
|
|
243
|
+
&& projected.routingPolicy.kind === 'routing-policy'
|
|
244
|
+
&& projected.agentInstances.agents.length === 2,
|
|
245
|
+
JSON.stringify(projected.problems || []));
|
|
246
|
+
|
|
247
|
+
const reverse = projected.ok ? projectAgentInstancesToLegacy(projected.agentInstances, legacy) : null;
|
|
248
|
+
check('legacy to v2 to legacy preserves exact assignment semantics',
|
|
249
|
+
reverse && reverse.ok
|
|
250
|
+
&& stableStringify(reverse.semantic) === stableStringify(legacySemanticAssignments(legacy)),
|
|
251
|
+
reverse ? JSON.stringify(reverse.problems || []) : 'projection failed');
|
|
252
|
+
check('reverse projection preserves unknown legacy top-level fields',
|
|
253
|
+
reverse && reverse.ok && stableStringify(reverse.document.projectNote) === stableStringify(legacy.projectNote));
|
|
254
|
+
check('reverse projection preserves unknown fields inside legacy role entries',
|
|
255
|
+
reverse && reverse.ok && stableStringify(reverse.document.roles.coder.customEntry) === stableStringify(legacy.roles.coder.customEntry));
|
|
256
|
+
|
|
257
|
+
const extendedLegacy = clone(legacy);
|
|
258
|
+
extendedLegacy.roles.coder.modelFamily = 'openai-gpt';
|
|
259
|
+
extendedLegacy.roles.coder.primary = {
|
|
260
|
+
provider: 'codex',
|
|
261
|
+
model: 'gpt-5-codex',
|
|
262
|
+
modelFamily: 'openai-gpt',
|
|
263
|
+
extension: 'preserve',
|
|
264
|
+
};
|
|
265
|
+
const extendedProjection = projectLegacyRoleStore(extendedLegacy);
|
|
266
|
+
if (extendedProjection.ok) {
|
|
267
|
+
extendedProjection.agentInstances.agents.find(agent => agent.legacyProjection.roleKey === 'coder').provider = 'claude';
|
|
268
|
+
extendedProjection.agentInstances.agents.find(agent => agent.legacyProjection.roleKey === 'coder').model = 'claude-sonnet-4-7';
|
|
269
|
+
}
|
|
270
|
+
const extendedReverse = extendedProjection.ok
|
|
271
|
+
? projectAgentInstancesToLegacy(extendedProjection.agentInstances, extendedLegacy)
|
|
272
|
+
: null;
|
|
273
|
+
const extendedRuntimeShape = extendedReverse && extendedReverse.ok
|
|
274
|
+
? require('../lib/role-fallback').validateRoleDefinitionShape('coder', extendedReverse.document.roles.coder)
|
|
275
|
+
: null;
|
|
276
|
+
check('reverse projection synchronizes legacy primary extensions and clears stale model-family claims',
|
|
277
|
+
extendedReverse && extendedReverse.ok
|
|
278
|
+
&& extendedReverse.document.roles.coder.provider === 'claude'
|
|
279
|
+
&& extendedReverse.document.roles.coder.model === 'claude-sonnet-4-7'
|
|
280
|
+
&& extendedReverse.document.roles.coder.primary.provider === 'claude'
|
|
281
|
+
&& extendedReverse.document.roles.coder.primary.model === 'claude-sonnet-4-7'
|
|
282
|
+
&& extendedReverse.document.roles.coder.primary.extension === 'preserve'
|
|
283
|
+
&& !Object.prototype.hasOwnProperty.call(extendedReverse.document.roles.coder, 'modelFamily')
|
|
284
|
+
&& !Object.prototype.hasOwnProperty.call(extendedReverse.document.roles.coder.primary, 'modelFamily')
|
|
285
|
+
&& extendedRuntimeShape && extendedRuntimeShape.ok,
|
|
286
|
+
JSON.stringify({ projection: extendedProjection.problems, reverse: extendedReverse && extendedReverse.problems, runtime: extendedRuntimeShape }));
|
|
287
|
+
|
|
288
|
+
const runtimeInvalidBase = clone(legacy);
|
|
289
|
+
runtimeInvalidBase.roles.coder.candidates = [{ provider: 'backup', model: 'unsafe;model' }];
|
|
290
|
+
const runtimeInvalidReverse = projected.ok
|
|
291
|
+
? projectAgentInstancesToLegacy(projected.agentInstances, runtimeInvalidBase)
|
|
292
|
+
: null;
|
|
293
|
+
check('reverse projection validates the final legacy document with the runtime consumer',
|
|
294
|
+
runtimeInvalidReverse && !runtimeInvalidReverse.ok
|
|
295
|
+
&& runtimeInvalidReverse.code === 'projection-runtime-invalid'
|
|
296
|
+
&& hasProblem(runtimeInvalidReverse, 'legacy-runtime-invalid'),
|
|
297
|
+
JSON.stringify(runtimeInvalidReverse));
|
|
298
|
+
|
|
299
|
+
const aliasLegacy = {
|
|
300
|
+
roles: {
|
|
301
|
+
implementer: {
|
|
302
|
+
provider: 'qwen',
|
|
303
|
+
model: 'qwen-coder',
|
|
304
|
+
aliasMetadata: 'must-survive',
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
const aliasProjection = projectLegacyRoleStore(aliasLegacy);
|
|
309
|
+
const aliasReverse = aliasProjection.ok ? projectAgentInstancesToLegacy(aliasProjection.agentInstances, aliasLegacy) : null;
|
|
310
|
+
check('legacy alias-like custom keys are not silently reinterpreted as new built-in roles',
|
|
311
|
+
aliasProjection.ok
|
|
312
|
+
&& aliasProjection.agentInstances.agents[0].role === legacyRoleId('implementer')
|
|
313
|
+
&& aliasProjection.agentInstances.agents[0].role.startsWith('legacy-role-')
|
|
314
|
+
&& aliasProjection.agentInstances.agents[0].role !== 'coder'
|
|
315
|
+
&& aliasProjection.agentInstances.agents[0].legacyProjection.roleKey === 'implementer');
|
|
316
|
+
check('reverse projection restores the original alias key instead of silently rewriting it',
|
|
317
|
+
aliasReverse && aliasReverse.ok
|
|
318
|
+
&& Object.prototype.hasOwnProperty.call(aliasReverse.document.roles, 'implementer')
|
|
319
|
+
&& !Object.prototype.hasOwnProperty.call(aliasReverse.document.roles, 'coder')
|
|
320
|
+
&& aliasReverse.document.roles.implementer.aliasMetadata === 'must-survive');
|
|
321
|
+
|
|
322
|
+
const distinctOldAndAliasLike = {
|
|
323
|
+
schemaVersion: 1,
|
|
324
|
+
roles: {
|
|
325
|
+
coder: { provider: 'codex' },
|
|
326
|
+
implementer: { provider: 'qwen' },
|
|
327
|
+
tester: { provider: 'claude' },
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
const distinctOldAndAliasLikeValidation = validateLegacyRoleStore(distinctOldAndAliasLike);
|
|
331
|
+
const distinctOldAndAliasLikeProjection = projectLegacyRoleStore(distinctOldAndAliasLike);
|
|
332
|
+
check('legacy canonical roles and later alias-like custom roles retain distinct identities',
|
|
333
|
+
distinctOldAndAliasLikeValidation.ok
|
|
334
|
+
&& distinctOldAndAliasLikeProjection.ok
|
|
335
|
+
&& distinctOldAndAliasLikeProjection.agentInstances.agents.some(agent => agent.role === 'coder')
|
|
336
|
+
&& distinctOldAndAliasLikeProjection.agentInstances.agents.some(agent => agent.role === legacyRoleId('implementer') && agent.role !== 'coder')
|
|
337
|
+
&& distinctOldAndAliasLikeProjection.agentInstances.agents.some(agent => agent.role === legacyRoleId('tester') && agent.role !== 'tester'),
|
|
338
|
+
JSON.stringify(distinctOldAndAliasLikeProjection.problems || []));
|
|
339
|
+
|
|
340
|
+
const customLegacy = {
|
|
341
|
+
schemaVersion: 1,
|
|
342
|
+
customTop: { preserve: true },
|
|
343
|
+
roles: {
|
|
344
|
+
'Special QA / 검수 01': { provider: 'codex', model: null, customRoleField: 'preserve' },
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
const customLegacyBefore = stableStringify(customLegacy);
|
|
348
|
+
const customLegacyProjection = projectLegacyRoleStore(customLegacy);
|
|
349
|
+
const customProjectedRole = legacyRoleId('Special QA / 검수 01');
|
|
350
|
+
const customLegacyReverse = customLegacyProjection.ok
|
|
351
|
+
? projectAgentInstancesToLegacy(customLegacyProjection.agentInstances, customLegacy)
|
|
352
|
+
: null;
|
|
353
|
+
check('legacy custom role keys accepted by existing --force behavior receive a stable conservative v2 role ID',
|
|
354
|
+
customLegacyProjection.ok
|
|
355
|
+
&& customProjectedRole.startsWith('legacy-role-')
|
|
356
|
+
&& customLegacyProjection.agentInstances.agents[0].role === customProjectedRole
|
|
357
|
+
&& customLegacyProjection.roleDefinitions.roles[customProjectedRole].requiredTier === 'read-only'
|
|
358
|
+
&& customLegacyProjection.roleDefinitions.roles[customProjectedRole].codeWrite === false
|
|
359
|
+
&& customLegacyProjection.roleDefinitions.roles[customProjectedRole].forbidden.includes('permission-widening-without-explicit-review'),
|
|
360
|
+
JSON.stringify(customLegacyProjection.problems || []));
|
|
361
|
+
check('custom legacy role projection round-trips the original key and unknown fields',
|
|
362
|
+
customLegacyReverse && customLegacyReverse.ok
|
|
363
|
+
&& Object.prototype.hasOwnProperty.call(customLegacyReverse.document.roles, 'Special QA / 검수 01')
|
|
364
|
+
&& customLegacyReverse.document.roles['Special QA / 검수 01'].customRoleField === 'preserve'
|
|
365
|
+
&& customLegacyReverse.document.customTop.preserve === true);
|
|
366
|
+
check('projection functions do not mutate the supplied legacy document',
|
|
367
|
+
stableStringify(customLegacy) === customLegacyBefore);
|
|
368
|
+
|
|
369
|
+
const protoLegacy = JSON.parse('{"schemaVersion":1,"roles":{"__proto__":{"provider":"codex","model":null}}}');
|
|
370
|
+
const protoProjection = projectLegacyRoleStore(protoLegacy);
|
|
371
|
+
const protoReverse = protoProjection.ok
|
|
372
|
+
? projectAgentInstancesToLegacy(protoProjection.agentInstances, { schemaVersion: 1, roles: {} })
|
|
373
|
+
: null;
|
|
374
|
+
const protoSemantic = protoReverse && protoReverse.ok ? legacySemanticAssignments(protoReverse.document) : null;
|
|
375
|
+
check('legacy __proto__ role keys remain own JSON data without prototype mutation or semantic loss',
|
|
376
|
+
protoProjection.ok
|
|
377
|
+
&& protoReverse && protoReverse.ok
|
|
378
|
+
&& Object.prototype.hasOwnProperty.call(protoReverse.document.roles, '__proto__')
|
|
379
|
+
&& Object.getPrototypeOf(protoReverse.document.roles) === Object.prototype
|
|
380
|
+
&& protoReverse.document.roles.__proto__.provider === 'codex'
|
|
381
|
+
&& protoSemantic && Object.prototype.hasOwnProperty.call(protoSemantic, '__proto__')
|
|
382
|
+
&& protoSemantic.__proto__.provider === 'codex'
|
|
383
|
+
&& ({}).provider === undefined,
|
|
384
|
+
JSON.stringify(protoReverse && protoReverse.problems || protoProjection.problems || []));
|
|
385
|
+
const normalizedCustomCollision = validateLegacyRoleStore({
|
|
386
|
+
schemaVersion: 1,
|
|
387
|
+
roles: {
|
|
388
|
+
'Special QA Role': { provider: 'codex' },
|
|
389
|
+
' special qa role ': { provider: 'claude' },
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
check('legacy custom role keys that normalize to one runtime identity fail as an alias collision',
|
|
393
|
+
!normalizedCustomCollision.ok && hasProblem(normalizedCustomCollision, 'legacy-role-alias-collision'));
|
|
394
|
+
const shorthandFormatCollision = validateLegacyRoleStore({
|
|
395
|
+
schemaVersion: 1,
|
|
396
|
+
roles: {
|
|
397
|
+
specialist: { provider: 'codex' },
|
|
398
|
+
['spec\u{1bca0}ialist']: { provider: 'claude' },
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
check('Shorthand Format Controls cannot create a second legacy role identity',
|
|
402
|
+
!shorthandFormatCollision.ok && hasProblem(shorthandFormatCollision, 'legacy-role-alias-collision'),
|
|
403
|
+
JSON.stringify(shorthandFormatCollision.problems));
|
|
404
|
+
|
|
405
|
+
const corruptSource = '{ \"apiKey\": \"SECRET-SHOULD-NOT-ECHO\" ';
|
|
406
|
+
const corrupt = parseLegacyRoleStoreText(corruptSource);
|
|
407
|
+
check('corrupt legacy JSON is distinct from schema invalidity and does not echo malformed source text',
|
|
408
|
+
!corrupt.ok
|
|
409
|
+
&& corrupt.state === 'invalid-json'
|
|
410
|
+
&& hasProblem(corrupt, 'invalid-json')
|
|
411
|
+
&& !JSON.stringify(corrupt).includes('SECRET-SHOULD-NOT-ECHO'));
|
|
412
|
+
const empty = parseLegacyRoleStoreText(' \n\t');
|
|
413
|
+
const bomOnly = parseLegacyRoleStoreText('\ufeff');
|
|
414
|
+
check('existing zero-byte or BOM-only legacy text fails closed instead of becoming an empty assignment store',
|
|
415
|
+
!empty.ok && empty.state === 'empty-document' && hasProblem(empty, 'empty-document')
|
|
416
|
+
&& !bomOnly.ok && bomOnly.state === 'empty-document' && hasProblem(bomOnly, 'empty-document'));
|
|
417
|
+
const futureLegacy = validateLegacyRoleStore({ schemaVersion: 9, roles: {} });
|
|
418
|
+
check('future legacy schema versions are rejected distinctly',
|
|
419
|
+
!futureLegacy.ok && hasProblem(futureLegacy, 'unsupported-schema-version', '9'));
|
|
420
|
+
|
|
421
|
+
for (const [label, bad, code] of [
|
|
422
|
+
['invalid legacy provider is rejected', { roles: { coder: { provider: '' } } }, 'invalid-legacy-provider'],
|
|
423
|
+
['invalid legacy model is rejected', { roles: { coder: { provider: 'codex', model: { id: 'x' } } } }, 'invalid-legacy-model'],
|
|
424
|
+
['invalid legacy persona is rejected', { roles: { coder: { provider: 'codex', persona: { text: 'x' } } } }, 'invalid-legacy-persona'],
|
|
425
|
+
]) {
|
|
426
|
+
const result = validateLegacyRoleStore(bad);
|
|
427
|
+
check(label, !result.ok && hasProblem(result, code));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const legacyReordered = {
|
|
431
|
+
roles: {
|
|
432
|
+
reviewer: clone(legacy.roles.reviewer),
|
|
433
|
+
coder: clone(legacy.roles.coder),
|
|
434
|
+
},
|
|
435
|
+
projectNote: clone(legacy.projectNote),
|
|
436
|
+
updatedAt: legacy.updatedAt,
|
|
437
|
+
schemaVersion: legacy.schemaVersion,
|
|
438
|
+
};
|
|
439
|
+
const projectedReordered = projectLegacyRoleStore(legacyReordered);
|
|
440
|
+
check('legacy projection is deterministic regardless of object insertion order',
|
|
441
|
+
projected.ok && projectedReordered.ok
|
|
442
|
+
&& stableStringify(projected.roleDefinitions) === stableStringify(projectedReordered.roleDefinitions)
|
|
443
|
+
&& stableStringify(projected.agentInstances) === stableStringify(projectedReordered.agentInstances)
|
|
444
|
+
&& stableStringify(projected.routingPolicy) === stableStringify(projectedReordered.routingPolicy));
|
|
445
|
+
check('projected legacy Agent IDs are stable functions of the original role key',
|
|
446
|
+
projected.ok
|
|
447
|
+
&& projected.agentInstances.agents.every(agent => agent.id === stableLegacyAgentId(agent.legacyProjection.roleKey)));
|
|
448
|
+
|
|
449
|
+
const roleDefinitions = projected.roleDefinitions;
|
|
450
|
+
const agentInstances = projected.agentInstances;
|
|
451
|
+
const routingPolicy = projected.routingPolicy;
|
|
452
|
+
check('projected role-definitions document passes strict v2 validation', validateRoleDefinitions(roleDefinitions).ok);
|
|
453
|
+
check('projected agent-instances document passes strict v2 validation', validateAgentInstances(agentInstances).ok);
|
|
454
|
+
check('projected routing-policy document passes strict v2 validation', validateRoutingPolicy(routingPolicy).ok);
|
|
455
|
+
check('null token budgets remain valid unknown values, not zero or unlimited',
|
|
456
|
+
agentInstances.agents.every(agent => agent.budget.inputTokens === null && agent.budget.outputTokens === null)
|
|
457
|
+
&& validateAgentInstances(agentInstances).ok);
|
|
458
|
+
|
|
459
|
+
const twoWorkers = clone(agentInstances);
|
|
460
|
+
twoWorkers.agents = [
|
|
461
|
+
{
|
|
462
|
+
...clone(agentInstances.agents[0]),
|
|
463
|
+
id: 'worker-a',
|
|
464
|
+
role: 'coder',
|
|
465
|
+
provider: 'qwen',
|
|
466
|
+
model: 'same-model',
|
|
467
|
+
legacyProjection: undefined,
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
...clone(agentInstances.agents[0]),
|
|
471
|
+
id: 'worker-b',
|
|
472
|
+
role: 'coder',
|
|
473
|
+
provider: 'qwen',
|
|
474
|
+
model: 'same-model',
|
|
475
|
+
legacyProjection: undefined,
|
|
476
|
+
},
|
|
477
|
+
].map(agent => {
|
|
478
|
+
delete agent.legacyProjection;
|
|
479
|
+
return agent;
|
|
480
|
+
});
|
|
481
|
+
check('multiple Agents may share one role/provider/model while retaining distinct IDs',
|
|
482
|
+
validateAgentInstances(twoWorkers).ok && twoWorkers.agents[0].id !== twoWorkers.agents[1].id);
|
|
483
|
+
|
|
484
|
+
const unknownRoleTop = clone(roleDefinitions);
|
|
485
|
+
unknownRoleTop.extra = true;
|
|
486
|
+
check('strict v2 validation reports unknown top-level fields distinctly',
|
|
487
|
+
hasProblem(validateRoleDefinitions(unknownRoleTop), 'unknown-field', undefined));
|
|
488
|
+
const unknownAgentNested = clone(agentInstances);
|
|
489
|
+
unknownAgentNested.agents[0].providerTemplate = { provider: 'x', model: 'y' };
|
|
490
|
+
check('strict v2 validation rejects unknown nested Agent fields',
|
|
491
|
+
hasProblem(validateAgentInstances(unknownAgentNested), 'unknown-field'));
|
|
492
|
+
const unsafeModelAgent = clone(agentInstances);
|
|
493
|
+
unsafeModelAgent.agents[0].model = 'unsafe;model';
|
|
494
|
+
check('strict v2 Agent validation rejects model identifiers that runtime dispatch cannot accept',
|
|
495
|
+
hasProblem(validateAgentInstances(unsafeModelAgent), 'invalid-agent-model'));
|
|
496
|
+
const futureV2 = clone(agentInstances);
|
|
497
|
+
futureV2.schemaVersion = 3;
|
|
498
|
+
check('unsupported v2 schema version is distinct from an unknown field',
|
|
499
|
+
hasProblem(validateAgentInstances(futureV2), 'unsupported-schema-version', '3')
|
|
500
|
+
&& !hasProblem(validateAgentInstances(futureV2), 'unknown-field'));
|
|
501
|
+
|
|
502
|
+
const validBundle = { roleDefinitions, agentInstances, routingPolicy };
|
|
503
|
+
const validBundleResult = validateConfigBundle(validBundle);
|
|
504
|
+
check('projected configuration bundle passes cross-document validation',
|
|
505
|
+
validBundleResult.ok,
|
|
506
|
+
JSON.stringify(validBundleResult.problems));
|
|
507
|
+
check('schema validity is not misreported as provider or executable readiness when assignments are incomplete',
|
|
508
|
+
validBundleResult.ok
|
|
509
|
+
&& validBundleResult.assignmentReady === false
|
|
510
|
+
&& validBundleResult.readinessScope === 'configured-enabled-agent-assignments-only'
|
|
511
|
+
&& validBundleResult.providerReadinessChecked === false
|
|
512
|
+
&& validBundleResult.assignmentGaps.some(gap => gap.pipeline === 'high-risk' && gap.role === 'architect' && gap.reason === 'no-enabled-agent')
|
|
513
|
+
&& projected.assignmentReady === false
|
|
514
|
+
&& projected.providerReadinessChecked === false
|
|
515
|
+
&& projected.assignmentGaps.length === validBundleResult.assignmentGaps.length,
|
|
516
|
+
JSON.stringify(validBundleResult.assignmentGaps));
|
|
517
|
+
|
|
518
|
+
function bundleWithAgents(agents) {
|
|
519
|
+
return {
|
|
520
|
+
roleDefinitions: clone(roleDefinitions),
|
|
521
|
+
agentInstances: { schemaVersion: 2, kind: 'agent-instances', agents, source: clone(agentInstances.source) },
|
|
522
|
+
routingPolicy: clone(routingPolicy),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function baseAgent(id, role = 'coder') {
|
|
526
|
+
return {
|
|
527
|
+
id,
|
|
528
|
+
role,
|
|
529
|
+
provider: 'codex',
|
|
530
|
+
model: null,
|
|
531
|
+
persona: '',
|
|
532
|
+
enabled: true,
|
|
533
|
+
maxConcurrency: 1,
|
|
534
|
+
sessionKeyPolicy: 'required-for-write',
|
|
535
|
+
budget: { inputTokens: null, outputTokens: null, retries: 1 },
|
|
536
|
+
fallback: [],
|
|
537
|
+
tags: [],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const fullyAssignedBundle = bundleWithAgents([
|
|
542
|
+
baseAgent('architect-a', 'architect'),
|
|
543
|
+
baseAgent('commander-a', 'commander'),
|
|
544
|
+
baseAgent('worker-a', 'coder'),
|
|
545
|
+
baseAgent('tester-a', 'tester'),
|
|
546
|
+
baseAgent('reviewer-a', 'reviewer'),
|
|
547
|
+
]);
|
|
548
|
+
const fullyAssignedResult = validateConfigBundle(fullyAssignedBundle);
|
|
549
|
+
check('a fully staffed default pipeline is structurally valid and assignment-ready only',
|
|
550
|
+
fullyAssignedResult.ok
|
|
551
|
+
&& fullyAssignedResult.assignmentReady === true
|
|
552
|
+
&& fullyAssignedResult.assignmentGaps.length === 0
|
|
553
|
+
&& fullyAssignedResult.readinessScope === 'configured-enabled-agent-assignments-only'
|
|
554
|
+
&& fullyAssignedResult.providerReadinessChecked === false,
|
|
555
|
+
JSON.stringify(fullyAssignedResult));
|
|
556
|
+
const structurallyInvalidReadyBundle = clone(fullyAssignedBundle);
|
|
557
|
+
structurallyInvalidReadyBundle.agentInstances.agents.find(agent => agent.id === 'worker-a').fallback = ['worker-b'];
|
|
558
|
+
structurallyInvalidReadyBundle.agentInstances.agents.push({ ...baseAgent('worker-b'), fallback: ['worker-a'] });
|
|
559
|
+
const structurallyInvalidReadyResult = validateConfigBundle(structurallyInvalidReadyBundle);
|
|
560
|
+
check('an invalid bundle can never report assignmentReady even when every pipeline role has an enabled Agent',
|
|
561
|
+
!structurallyInvalidReadyResult.ok
|
|
562
|
+
&& structurallyInvalidReadyResult.assignmentReady === false
|
|
563
|
+
&& hasProblem(structurallyInvalidReadyResult, 'fallback-cycle'));
|
|
564
|
+
|
|
565
|
+
const missingFallbackBundle = bundleWithAgents([
|
|
566
|
+
{ ...baseAgent('worker-a'), fallback: ['worker-missing'] },
|
|
567
|
+
]);
|
|
568
|
+
check('bundle validation rejects fallback targets that do not exist',
|
|
569
|
+
hasProblem(validateConfigBundle(missingFallbackBundle), 'fallback-target-missing', 'worker-missing'));
|
|
570
|
+
|
|
571
|
+
const mismatchBundle = bundleWithAgents([
|
|
572
|
+
{ ...baseAgent('worker-a', 'coder'), fallback: ['reviewer-a'] },
|
|
573
|
+
baseAgent('reviewer-a', 'reviewer'),
|
|
574
|
+
]);
|
|
575
|
+
check('bundle validation rejects fallback to a different role',
|
|
576
|
+
hasProblem(validateConfigBundle(mismatchBundle), 'fallback-role-mismatch', 'reviewer-a'));
|
|
577
|
+
|
|
578
|
+
const weakenedSessionFallback = bundleWithAgents([
|
|
579
|
+
{ ...baseAgent('worker-a', 'coder'), fallback: ['worker-b'] },
|
|
580
|
+
{ ...baseAgent('worker-b', 'coder'), sessionKeyPolicy: 'forbidden' },
|
|
581
|
+
]);
|
|
582
|
+
const weakenedSessionFallbackResult = validateConfigBundle(weakenedSessionFallback);
|
|
583
|
+
check('write Agents require stable session keys and fallback cannot weaken the source session policy',
|
|
584
|
+
hasProblem(weakenedSessionFallbackResult, 'write-agent-session-key-required')
|
|
585
|
+
&& hasProblem(weakenedSessionFallbackResult, 'fallback-session-policy-weakening', 'worker-b'));
|
|
586
|
+
|
|
587
|
+
const releaseWithoutSession = bundleWithAgents([
|
|
588
|
+
{ ...baseAgent('release-a', 'release'), sessionKeyPolicy: 'forbidden' },
|
|
589
|
+
]);
|
|
590
|
+
check('release-capable Agents require stable session keys even when codeWrite is false',
|
|
591
|
+
hasProblem(validateConfigBundle(releaseWithoutSession), 'write-agent-session-key-required'));
|
|
592
|
+
|
|
593
|
+
const selfFallbackBundle = bundleWithAgents([
|
|
594
|
+
{ ...baseAgent('worker-a'), fallback: ['worker-a'] },
|
|
595
|
+
]);
|
|
596
|
+
check('self-fallback is rejected before execution planning',
|
|
597
|
+
hasProblem(validateConfigBundle(selfFallbackBundle), 'self-fallback', 'worker-a'));
|
|
598
|
+
|
|
599
|
+
const cycleBundle = bundleWithAgents([
|
|
600
|
+
{ ...baseAgent('worker-a'), fallback: ['worker-b'] },
|
|
601
|
+
{ ...baseAgent('worker-b'), fallback: ['worker-a'] },
|
|
602
|
+
]);
|
|
603
|
+
check('fallback cycles are rejected deterministically',
|
|
604
|
+
hasProblem(validateConfigBundle(cycleBundle), 'fallback-cycle', 'worker-a'));
|
|
605
|
+
|
|
606
|
+
const templateFallback = clone(agentInstances);
|
|
607
|
+
templateFallback.agents[0].fallback = [{ provider: 'qwen', model: 'x' }];
|
|
608
|
+
check('fallback accepts Agent IDs only and rejects provider/model templates',
|
|
609
|
+
hasProblem(validateAgentInstances(templateFallback), 'invalid-fallback-agent-id'));
|
|
610
|
+
|
|
611
|
+
const missingHighRisk = clone(routingPolicy);
|
|
612
|
+
missingHighRisk.pipelines['high-risk'] = ['architect', 'commander', 'coder', 'reviewer'];
|
|
613
|
+
check('high-risk routing policy requires an explicit Tester stage',
|
|
614
|
+
hasProblem(validateRoutingPolicy(missingHighRisk), 'high-risk-role-missing', 'tester'));
|
|
615
|
+
const emptyNormalPipeline = clone(routingPolicy);
|
|
616
|
+
emptyNormalPipeline.pipelines.normal = [];
|
|
617
|
+
check('named routing pipelines cannot be empty no-op configurations',
|
|
618
|
+
hasProblem(validateRoutingPolicy(emptyNormalPipeline), 'empty-pipeline'));
|
|
619
|
+
|
|
620
|
+
const missingHighRiskRequirements = clone(routingPolicy);
|
|
621
|
+
delete missingHighRiskRequirements.requirements['high-risk'];
|
|
622
|
+
check('high-risk routing policy requires an explicit fail-closed requirement block',
|
|
623
|
+
hasProblem(validateRoutingPolicy(missingHighRiskRequirements), 'high-risk-requirements-required'));
|
|
624
|
+
const missingNormalRequirements = clone(routingPolicy);
|
|
625
|
+
delete missingNormalRequirements.requirements.normal;
|
|
626
|
+
check('every routing pipeline requires its own explicit safety requirement block',
|
|
627
|
+
hasProblem(validateRoutingPolicy(missingNormalRequirements), 'pipeline-requirement-missing')
|
|
628
|
+
&& validateRoutingPolicy(missingNormalRequirements).problems.some(problem => problem.path.endsWith('.requirements.normal')));
|
|
629
|
+
const reviewerRequirementWithoutStage = clone(routingPolicy);
|
|
630
|
+
reviewerRequirementWithoutStage.pipelines.normal = ['commander', 'coder', 'tester'];
|
|
631
|
+
check('independent-reviewer requirements cannot survive without a Reviewer stage',
|
|
632
|
+
hasProblem(validateRoutingPolicy(reviewerRequirementWithoutStage), 'independent-reviewer-role-missing', 'reviewer'));
|
|
633
|
+
const namedPipelineContractBroken = clone(routingPolicy);
|
|
634
|
+
namedPipelineContractBroken.pipelines.normal = ['commander', 'coder', 'reviewer'];
|
|
635
|
+
namedPipelineContractBroken.pipelines['high-risk'] = ['commander', 'architect', 'coder', 'reviewer', 'tester'];
|
|
636
|
+
const namedPipelineContractResult = validateRoutingPolicy(namedPipelineContractBroken);
|
|
637
|
+
check('named pipeline minimum stages and safety order are fail-closed',
|
|
638
|
+
hasProblem(namedPipelineContractResult, 'named-pipeline-role-missing', 'tester')
|
|
639
|
+
&& hasProblem(namedPipelineContractResult, 'high-risk-role-order-invalid', 'architect -> commander -> coder -> tester -> reviewer'));
|
|
640
|
+
const weakenedHighRisk = clone(routingPolicy);
|
|
641
|
+
weakenedHighRisk.requirements['high-risk'].humanApproval = false;
|
|
642
|
+
weakenedHighRisk.requirements['high-risk'].leaseForWrites = false;
|
|
643
|
+
const weakenedHighRiskResult = validateRoutingPolicy(weakenedHighRisk);
|
|
644
|
+
check('high-risk human approval and write-lease requirements cannot be silently disabled',
|
|
645
|
+
hasProblem(weakenedHighRiskResult, 'high-risk-requirement-disabled', 'humanApproval')
|
|
646
|
+
&& hasProblem(weakenedHighRiskResult, 'high-risk-requirement-disabled', 'leaseForWrites'));
|
|
647
|
+
const weakenedNormal = clone(routingPolicy);
|
|
648
|
+
weakenedNormal.requirements.normal.independentReviewer = false;
|
|
649
|
+
weakenedNormal.requirements.normal.gate = false;
|
|
650
|
+
const weakenedNormalResult = validateRoutingPolicy(weakenedNormal);
|
|
651
|
+
check('normal pipeline independent review and gate minimums cannot be silently disabled',
|
|
652
|
+
hasProblem(weakenedNormalResult, 'named-pipeline-requirement-disabled', 'independentReviewer')
|
|
653
|
+
&& hasProblem(weakenedNormalResult, 'named-pipeline-requirement-disabled', 'gate'));
|
|
654
|
+
const weakenedTiny = clone(routingPolicy);
|
|
655
|
+
weakenedTiny.requirements.tiny.verifyClaim = false;
|
|
656
|
+
weakenedTiny.requirements.tiny.leaseForWrites = false;
|
|
657
|
+
const weakenedTinyResult = validateRoutingPolicy(weakenedTiny);
|
|
658
|
+
check('tiny pipeline still requires evidence verification and write leases',
|
|
659
|
+
hasProblem(weakenedTinyResult, 'named-pipeline-requirement-disabled', 'verifyClaim')
|
|
660
|
+
&& hasProblem(weakenedTinyResult, 'named-pipeline-requirement-disabled', 'leaseForWrites'));
|
|
661
|
+
|
|
662
|
+
const aliasInV2 = clone(routingPolicy);
|
|
663
|
+
aliasInV2.pipelines.normal = ['orchestrator', 'coder', 'tester', 'reviewer'];
|
|
664
|
+
check('v2 routing persists canonical compatibility role IDs rather than display aliases',
|
|
665
|
+
hasProblem(validateRoutingPolicy(aliasInV2), 'non-canonical-pipeline-role'));
|
|
666
|
+
|
|
667
|
+
const unknownRoleBundle = bundleWithAgents([baseAgent('worker-a', 'custom-missing')]);
|
|
668
|
+
check('bundle validation rejects Agent roles absent from built-ins and project definitions',
|
|
669
|
+
hasProblem(validateConfigBundle(unknownRoleBundle), 'unknown-agent-role', 'custom-missing'));
|
|
670
|
+
|
|
671
|
+
const customRoleDefinitions = clone(roleDefinitions);
|
|
672
|
+
customRoleDefinitions.roles['data-specialist'] = {
|
|
673
|
+
label: { ko: '데이터 담당', en: 'Data specialist' },
|
|
674
|
+
responsibilities: ['implement bounded data changes'],
|
|
675
|
+
requiredTier: 'project-write',
|
|
676
|
+
codeWrite: true,
|
|
677
|
+
approve: false,
|
|
678
|
+
release: false,
|
|
679
|
+
forbidden: ['approve-own-work'],
|
|
680
|
+
requiredInputs: ['task', 'allowedFiles'],
|
|
681
|
+
requiredOutputs: ['changedFiles', 'tests', 'evidence'],
|
|
682
|
+
contextPolicy: 'assigned-files-and-contract',
|
|
683
|
+
defaultBudget: { inputTokens: null, outputTokens: null, retries: 1 },
|
|
684
|
+
};
|
|
685
|
+
const constructorRoleDefinitions = clone(roleDefinitions);
|
|
686
|
+
constructorRoleDefinitions.roles.constructor = clone(customRoleDefinitions.roles['data-specialist']);
|
|
687
|
+
check('custom role IDs that shadow Object prototype names are not mistaken for built-ins',
|
|
688
|
+
validateRoleDefinitions(constructorRoleDefinitions).ok,
|
|
689
|
+
JSON.stringify(validateRoleDefinitions(constructorRoleDefinitions).problems));
|
|
690
|
+
const unsafeReleaseRoleDefinitions = clone(customRoleDefinitions);
|
|
691
|
+
unsafeReleaseRoleDefinitions.roles['data-specialist'].requiredTier = 'read-only';
|
|
692
|
+
unsafeReleaseRoleDefinitions.roles['data-specialist'].release = true;
|
|
693
|
+
check('effective release and code-write capabilities require their minimum permission tiers',
|
|
694
|
+
hasProblem(validateRoleDefinitions(unsafeReleaseRoleDefinitions), 'code-write-tier-too-low')
|
|
695
|
+
&& hasProblem(validateRoleDefinitions(unsafeReleaseRoleDefinitions), 'release-tier-too-low'));
|
|
696
|
+
const customAgent = baseAgent('data-worker', 'data-specialist');
|
|
697
|
+
const customBundle = {
|
|
698
|
+
roleDefinitions: customRoleDefinitions,
|
|
699
|
+
agentInstances: { schemaVersion: 2, kind: 'agent-instances', agents: [customAgent], source: clone(agentInstances.source) },
|
|
700
|
+
routingPolicy: clone(routingPolicy),
|
|
701
|
+
};
|
|
702
|
+
check('explicit project role definitions allow a custom Agent role without provider coupling',
|
|
703
|
+
validateConfigBundle(customBundle).ok,
|
|
704
|
+
JSON.stringify(validateConfigBundle(customBundle).problems));
|
|
705
|
+
const controlRoleDefinitions = clone(customRoleDefinitions);
|
|
706
|
+
controlRoleDefinitions.roles['data-specialist'].label.en = 'bad\u0001label';
|
|
707
|
+
check('strict persisted text validation rejects the full C0 control range',
|
|
708
|
+
hasProblem(validateRoleDefinitions(controlRoleDefinitions), 'invalid-role-label-en'));
|
|
709
|
+
const providerCoupledRole = clone(customRoleDefinitions);
|
|
710
|
+
providerCoupledRole.roles['data-specialist'].provider = 'codex';
|
|
711
|
+
providerCoupledRole.roles['data-specialist'].model = 'some-model';
|
|
712
|
+
const providerCoupledResult = validateRoleDefinitions(providerCoupledRole);
|
|
713
|
+
check('Role definitions reject provider/model coupling as strict unknown fields',
|
|
714
|
+
hasProblem(providerCoupledResult, 'unknown-field')
|
|
715
|
+
&& providerCoupledResult.problems.some(problem => problem.path.endsWith('.provider'))
|
|
716
|
+
&& providerCoupledResult.problems.some(problem => problem.path.endsWith('.model')));
|
|
717
|
+
|
|
718
|
+
const missingRoleParentBundle = clone(customBundle);
|
|
719
|
+
missingRoleParentBundle.roleDefinitions.roles['data-specialist'].extends = 'missing-role';
|
|
720
|
+
check('role inheritance rejects a missing parent role',
|
|
721
|
+
hasProblem(validateConfigBundle(missingRoleParentBundle), 'role-extends-target-missing', 'missing-role'));
|
|
722
|
+
const roleCycleBundle = clone(customBundle);
|
|
723
|
+
roleCycleBundle.roleDefinitions.roles['data-specialist'].extends = 'data-reviewer';
|
|
724
|
+
roleCycleBundle.roleDefinitions.roles['data-reviewer'] = {
|
|
725
|
+
label: { ko: '데이터 검수', en: 'Data reviewer' },
|
|
726
|
+
extends: 'data-specialist',
|
|
727
|
+
responsibilities: ['review bounded data changes'],
|
|
728
|
+
requiredTier: 'read-only',
|
|
729
|
+
codeWrite: false,
|
|
730
|
+
approve: true,
|
|
731
|
+
release: false,
|
|
732
|
+
forbidden: ['approve-own-work'],
|
|
733
|
+
requiredInputs: [],
|
|
734
|
+
requiredOutputs: [],
|
|
735
|
+
contextPolicy: 'bounded-review',
|
|
736
|
+
defaultBudget: { inputTokens: null, outputTokens: null, retries: 1 },
|
|
737
|
+
};
|
|
738
|
+
check('role inheritance cycles are rejected deterministically',
|
|
739
|
+
hasProblem(validateConfigBundle(roleCycleBundle), 'role-extends-cycle', 'data-specialist'));
|
|
740
|
+
|
|
741
|
+
const mismatchedReverseAgents = clone(agentInstances);
|
|
742
|
+
mismatchedReverseAgents.agents[0].role = 'reviewer';
|
|
743
|
+
const mismatchedReverse = projectAgentInstancesToLegacy(mismatchedReverseAgents, legacy);
|
|
744
|
+
check('reverse projection rejects a legacy role-key and canonical-role mismatch',
|
|
745
|
+
!mismatchedReverse.ok && hasProblem(mismatchedReverse, 'legacy-role-mismatch'));
|
|
746
|
+
|
|
747
|
+
const duplicateLegacyProjection = clone(agentInstances);
|
|
748
|
+
duplicateLegacyProjection.agents.push({
|
|
749
|
+
...clone(duplicateLegacyProjection.agents[0]),
|
|
750
|
+
id: 'duplicate-projection',
|
|
751
|
+
});
|
|
752
|
+
const duplicateReverse = projectAgentInstancesToLegacy(duplicateLegacyProjection, legacy);
|
|
753
|
+
check('reverse projection rejects multiple primary Agents for one legacy role key',
|
|
754
|
+
!duplicateReverse.ok && hasProblem(duplicateReverse, 'duplicate-legacy-role-key'));
|
|
755
|
+
|
|
756
|
+
const collidingBundle = bundleWithAgents([
|
|
757
|
+
{ ...baseAgent('legacy-custom-a', legacyRoleId('Special QA Role')), legacyProjection: { file: 'agent-roles.json', roleKey: 'Special QA Role', primary: true } },
|
|
758
|
+
{ ...baseAgent('legacy-custom-b', legacyRoleId('Special QA Role')), legacyProjection: { file: 'agent-roles.json', roleKey: ' special qa role ', primary: true } },
|
|
759
|
+
]);
|
|
760
|
+
check('bundle validation rejects distinct custom legacy keys that normalize to one runtime identity',
|
|
761
|
+
hasProblem(validateConfigBundle(collidingBundle), 'legacy-role-alias-collision', legacyRoleId('Special QA Role')));
|
|
762
|
+
const mismatchedBundleProjection = bundleWithAgents([
|
|
763
|
+
{ ...baseAgent('legacy-reviewer-as-coder', 'reviewer'), legacyProjection: { file: 'agent-roles.json', roleKey: 'coder', primary: true } },
|
|
764
|
+
]);
|
|
765
|
+
check('bundle validation rejects a primary legacy projection whose semantic role differs from the Agent role',
|
|
766
|
+
hasProblem(validateConfigBundle(mismatchedBundleProjection), 'legacy-role-mismatch', 'coder'));
|
|
767
|
+
const disabledPrimaryProjection = bundleWithAgents([
|
|
768
|
+
{ ...baseAgent('legacy-disabled', 'coder'), enabled: false, legacyProjection: { file: 'agent-roles.json', roleKey: 'coder', primary: true } },
|
|
769
|
+
]);
|
|
770
|
+
const disabledPrimaryBundleResult = validateConfigBundle(disabledPrimaryProjection);
|
|
771
|
+
const disabledPrimaryReverseResult = projectAgentInstancesToLegacy(disabledPrimaryProjection.agentInstances, { schemaVersion: 1, roles: {} });
|
|
772
|
+
check('disabled primary Agents cannot be projected into legacy storage as silently enabled assignments',
|
|
773
|
+
hasProblem(disabledPrimaryBundleResult, 'disabled-primary-legacy-projection', 'legacy-disabled')
|
|
774
|
+
&& !disabledPrimaryReverseResult.ok
|
|
775
|
+
&& hasProblem(disabledPrimaryReverseResult, 'disabled-primary-legacy-projection', 'legacy-disabled'));
|
|
776
|
+
|
|
777
|
+
const defaultPolicy = defaultRoutingPolicyDocument();
|
|
778
|
+
check('default v2 routing remains suggestion-only and contains no execution flag',
|
|
779
|
+
defaultPolicy.defaultMode === 'suggest'
|
|
780
|
+
&& !Object.prototype.hasOwnProperty.call(defaultPolicy, 'execute')
|
|
781
|
+
&& validateRoutingPolicy(defaultPolicy).ok);
|
|
782
|
+
check('standalone defaults and legacy projections carry distinct truthful provenance',
|
|
783
|
+
defaultPolicy.source.kind === 'built-in-default'
|
|
784
|
+
&& defaultPolicy.source.file === 'lib/role-agent-schema.js'
|
|
785
|
+
&& projected.routingPolicy.source.kind === 'legacy-agent-roles-v1'
|
|
786
|
+
&& projected.routingPolicy.source.file === 'agent-roles.json');
|
|
787
|
+
check('Role definitions contain no provider or model coupling in the legacy projection',
|
|
788
|
+
Object.keys(projected.roleDefinitions.roles).length === 0
|
|
789
|
+
&& !stableStringify(projected.roleDefinitions).includes('provider')
|
|
790
|
+
&& !stableStringify(projected.roleDefinitions).includes('model'));
|
|
791
|
+
|
|
792
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-role-schema-'));
|
|
793
|
+
try {
|
|
794
|
+
const stateDir = path.join(temp, '.leerness');
|
|
795
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
796
|
+
const legacyFile = path.join(stateDir, STORE_FILES.legacyAssignments);
|
|
797
|
+
const legacyBytes = JSON.stringify(legacy, null, 2) + '\n';
|
|
798
|
+
fs.writeFileSync(legacyFile, legacyBytes, 'utf8');
|
|
799
|
+
const beforeBytes = fs.readFileSync(legacyFile);
|
|
800
|
+
const run = childProcess.spawnSync(process.execPath, [CLI, 'roles', 'list', '--path', temp, '--json'], {
|
|
801
|
+
encoding: 'utf8',
|
|
802
|
+
timeout: 30000,
|
|
803
|
+
env: {
|
|
804
|
+
...process.env,
|
|
805
|
+
LEERNESS_INTERNAL: '1',
|
|
806
|
+
LEERNESS_OFFLINE: '1',
|
|
807
|
+
LEERNESS_NO_PROMPT: '1',
|
|
808
|
+
LEERNESS_NO_STALE_CHECK: '1',
|
|
809
|
+
LEERNESS_NO_AUTO_ROADMAP: '1',
|
|
810
|
+
LEERNESS_NO_AUTOCHCP: '1',
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
let parsed = null;
|
|
814
|
+
try { parsed = JSON.parse(run.stdout); } catch {}
|
|
815
|
+
const afterBytes = fs.readFileSync(legacyFile);
|
|
816
|
+
check('existing CLI still reads a valid agent-roles.json without a ready provider or model execution',
|
|
817
|
+
run.status === 0
|
|
818
|
+
&& parsed && parsed.count === 2
|
|
819
|
+
&& parsed.roles.coder.provider === 'codex'
|
|
820
|
+
&& parsed.roles.reviewer.provider === 'claude'
|
|
821
|
+
&& run.stderr === '',
|
|
822
|
+
`exit=${run.status} stdout=${String(run.stdout || '').slice(0, 300)} stderr=${String(run.stderr || '').slice(0, 300)}`);
|
|
823
|
+
check('existing roles list compatibility read leaves legacy bytes unchanged',
|
|
824
|
+
Buffer.compare(beforeBytes, afterBytes) === 0);
|
|
825
|
+
} finally {
|
|
826
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (failures.length) {
|
|
830
|
+
console.error(`Role/Agent/Routing schema probe failed: ${failures.length}/${total}`);
|
|
831
|
+
failures.forEach(label => console.error(`- ${label}`));
|
|
832
|
+
process.exit(1);
|
|
833
|
+
}
|
|
834
|
+
console.log(`Role/Agent/Routing schema probe: ${total}/${total} passed`);
|