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.
@@ -0,0 +1,2010 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const cp = require('child_process');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const CLI = path.join(__dirname, '..', 'bin', 'leerness.js');
10
+ const {
11
+ validateRoleDefinitionShape,
12
+ normalizeRoleDefinition,
13
+ normalizeAvailability,
14
+ normalizeAvailabilityObservation,
15
+ inferModelFamily,
16
+ normalizeExecutorIdentity,
17
+ assessReviewerIndependence,
18
+ resolveRoleFallback,
19
+ selectFallbackOption,
20
+ normalizeExecutionEvent,
21
+ appendExecutionEvent,
22
+ readExecutionEvents,
23
+ appendAvailabilityObservation,
24
+ appendAvailabilityClear,
25
+ readAvailabilityObservations,
26
+ availabilityExtrasForCandidate,
27
+ normalizeApprover,
28
+ } = require('../lib/role-fallback');
29
+ const { agentsCmd, _parseAgentPositional, _findAgentAction } = require('../lib/agents');
30
+ const { classify, applyDeclaredRiskFloor, plan: routingPlan } = require('../lib/routing');
31
+ const roleStore = require('../lib/role-store');
32
+ const { _executionProvenanceSummary } = require('../lib/session-close');
33
+
34
+ let passed = 0;
35
+ let failed = 0;
36
+ function test(name, fn) {
37
+ try {
38
+ fn();
39
+ passed++;
40
+ console.log(`✓ ${name}`);
41
+ } catch (error) {
42
+ failed++;
43
+ console.error(`✗ ${name}: ${error.stack || error.message}`);
44
+ }
45
+ }
46
+
47
+ function run(args, opts = {}) {
48
+ return cp.spawnSync(process.execPath, [CLI, ...args], {
49
+ encoding: 'utf8',
50
+ timeout: opts.timeout || 30000,
51
+ cwd: opts.cwd || path.dirname(CLI),
52
+ env: {
53
+ ...process.env,
54
+ LEERNESS_NO_BANNER: '1',
55
+ LEERNESS_NO_PROMPT: '1',
56
+ LEERNESS_NO_DRIFT_CHECK: '1',
57
+ LEERNESS_NO_STALE_CHECK: '1',
58
+ ...(opts.env || {}),
59
+ },
60
+ });
61
+ }
62
+
63
+ function json(result) {
64
+ try { return JSON.parse(String(result.stdout || '').trim()); }
65
+ catch { return null; }
66
+ }
67
+
68
+ function provider(id, envFlag) {
69
+ return { id, bin: process.execPath, envFlag, versionArgs: ['--version'], desc: id };
70
+ }
71
+
72
+ function checkFor(ready) {
73
+ return (definition) => ({
74
+ id: definition.id,
75
+ status: ready.has(definition.id) ? 'ready' : 'disabled',
76
+ installed: true,
77
+ enabled: ready.has(definition.id),
78
+ version: 'synthetic-1',
79
+ auth: ready.has(definition.id) ? 'ok' : 'unknown',
80
+ });
81
+ }
82
+
83
+ function seedRoleExecutionParent(root, taskId, attemptId, role = 'coder') {
84
+ return appendExecutionEvent(root, {
85
+ event: 'execution.started',
86
+ taskId,
87
+ attemptId,
88
+ requestedRole: role,
89
+ actualExecutor: { provider: 'live', identitySource: 'synthetic-test' },
90
+ result: { summary: 'synthetic role execution parent' },
91
+ executed: true,
92
+ });
93
+ }
94
+
95
+ test('agent task parser skips option values by argv position and accepts flags before text', () => {
96
+ assert.strictEqual(_parseAgentPositional(['fix auth', '--to', 'codex', '--model', 'm1']), 'fix auth');
97
+ assert.strictEqual(_parseAgentPositional(['--to', 'codex', 'fix auth', '--role', 'coder']), 'fix auth');
98
+ assert.strictEqual(_parseAgentPositional(['provider', 'fix auth', '--approved-by', 'owner'], 1), 'fix auth');
99
+ assert.strictEqual(_parseAgentPositional(['--json', '--', '-literal task']), '-literal task');
100
+ });
101
+
102
+ test('agent action parser accepts flags before fallback/record action tokens', () => {
103
+ assert.deepStrictEqual(_findAgentAction(['--role', 'coder', 'provider', 'task'], new Set(['provider'])), { action: 'provider', index: 2 });
104
+ assert.deepStrictEqual(_findAgentAction(['--task', 'T-1', 'completed', '--summary', 'done'], new Set(['completed'])), { action: 'completed', index: 2 });
105
+ });
106
+
107
+ test('MCP availability authentication enum matches the internal ok/no/unknown contract', () => {
108
+ const tool = require('../lib/mcp-tools').find(item => item.name === 'leerness_agents_availability');
109
+ assert.ok(tool);
110
+ assert.deepStrictEqual(tool.inputSchema.properties.authenticated.enum, ['ok', 'no', 'unknown']);
111
+ });
112
+
113
+ test('reviewer independence requires concrete model identities and known different model families', () => {
114
+ assert.strictEqual(assessReviewerIndependence({ provider: 'gateway-a', modelFamily: 'claude' }, { provider: 'gateway-b', modelFamily: 'openai-gpt' }).reviewerIndependent, null);
115
+ assert.strictEqual(assessReviewerIndependence({ provider: 'gateway-a', model: 'review-a', modelFamily: 'claude' }, { provider: 'gateway-b', model: 'worker-b', modelFamily: 'claude' }).reviewerIndependent, false);
116
+ assert.strictEqual(assessReviewerIndependence({ provider: 'same', model: 'review-a', modelFamily: 'claude' }, { provider: 'same', model: 'worker-b', modelFamily: 'openai-gpt' }).reviewerIndependent, false);
117
+ const sameProvider = assessReviewerIndependence({ provider: 'same', model: 'gpt-5.6' }, { provider: 'same', model: 'claude-sonnet-4-7' });
118
+ assert.strictEqual(sameProvider.reviewerIndependent, false);
119
+ assert.strictEqual(sameProvider.basis, 'same-provider');
120
+ assert.strictEqual(assessReviewerIndependence({ provider: 'reviewer', model: 'gpt-5.6' }, { provider: 'implementer', model: 'claude-sonnet-4-7' }).reviewerIndependent, true);
121
+ const providerMissing = assessReviewerIndependence({ model: 'gpt-5.6' }, { model: 'claude-sonnet-4-7' });
122
+ assert.strictEqual(providerMissing.reviewerIndependent, null);
123
+ assert.strictEqual(providerMissing.basis, 'provider-unverified');
124
+ assert.strictEqual(normalizeExecutorIdentity({ model: 'claude-opus-4-7', modelFamily: 'openai-gpt' }).modelFamily, 'claude');
125
+ assert.strictEqual(assessReviewerIndependence({ provider: 'a' }, { provider: 'b' }).reviewerIndependent, null);
126
+ assert.strictEqual(inferModelFamily('claude-gpt-5'), null);
127
+ assert.strictEqual(assessReviewerIndependence(
128
+ { provider: 'gateway-a', model: 'claude-gpt-5' },
129
+ { provider: 'gateway-b', model: 'gpt-5' },
130
+ ).reviewerIndependent, null);
131
+ const invisibleAlias = assessReviewerIndependence(
132
+ { provider: 'gate\u200bway', model: 'gpt-5.6' },
133
+ { provider: 'gateway', model: 'claude-sonnet-4-7' },
134
+ );
135
+ assert.strictEqual(invisibleAlias.reviewerIndependent, false);
136
+ assert.strictEqual(invisibleAlias.basis, 'same-provider');
137
+ });
138
+
139
+ test('high-risk routing rejects role assignments combined from different store revisions', () => {
140
+ let calls = 0;
141
+ const mixed = routingPlan('.', { tier: 'high-risk' }, {
142
+ _resolveRole: (_root, role) => {
143
+ calls += 1;
144
+ const secondSnapshot = calls === 4;
145
+ return {
146
+ role,
147
+ provider: secondSnapshot ? 'claude-provider' : 'gpt-provider',
148
+ model: secondSnapshot ? 'claude-sonnet-4-7' : 'gpt-5.6',
149
+ modelFamily: secondSnapshot ? 'claude' : 'openai-gpt',
150
+ storeRevision: secondSnapshot ? 'sha256:snapshot-b' : 'sha256:snapshot-a',
151
+ };
152
+ },
153
+ });
154
+ assert.strictEqual(mixed.roleStoreFailure.code, 'role_store_revision_mixed');
155
+ assert.strictEqual(mixed.reviewerIndependent, null);
156
+ assert.strictEqual(mixed.reviewerIndependence.basis, 'role-store-snapshot-invalid');
157
+ });
158
+
159
+ test('approval and risk normalization reject punctuation and Unicode invisible bypasses', () => {
160
+ const nonIdentities = ['.', '!!!', '\u034f', '\u115f', '\u1160', '\u2063', '\u2800', '\u3164', '\uffa0'];
161
+ for (const value of nonIdentities) assert.strictEqual(normalizeApprover(value), '');
162
+ assert.strictEqual(normalizeApprover('Owner 7'), 'Owner 7');
163
+
164
+ for (const invisible of ['\u034f', '\u115f', '\u1160', '\u2063', '\u2064', '\u2800', '\u3164', '\uffa0', '\u{1bca0}']) {
165
+ const classified = classify(`pa${invisible}yment re${invisible}fund typo`);
166
+ assert.strictEqual(classified.tier, 'high-risk', `risk bypass accepted for U+${invisible.codePointAt(0).toString(16)}`);
167
+ }
168
+
169
+ const downgrade = classify('payment refund typo', { tier: 'tiny' });
170
+ assert.strictEqual(applyDeclaredRiskFloor(downgrade, { approvedBy: '.', reason: 'valid reason' }).tier, 'high-risk');
171
+ assert.strictEqual(applyDeclaredRiskFloor(downgrade, { approvedBy: 'owner', reason: '!!!' }).tier, 'high-risk');
172
+ });
173
+
174
+ test('stored role validation rejects malformed nested policy input instead of weakening it', () => {
175
+ assert.strictEqual(validateRoleDefinitionShape('coder', { provider: 'codex', model: null }).ok, true);
176
+ assert.strictEqual(validateRoleDefinitionShape('coder', { primary: { provider: 'codex' }, fallbackPolicy: 'strict' }).ok, true);
177
+ assert.strictEqual(validateRoleDefinitionShape('coder', { primary: { provider: '' }, fallbackPolicy: 'strict' }).ok, false);
178
+ assert.strictEqual(validateRoleDefinitionShape('coder', { primary: { provider: 'codex' }, candidates: { provider: 'backup' } }).ok, false);
179
+ assert.strictEqual(validateRoleDefinitionShape('coder', { provider: 'codex', fallbackPolicy: 'strcit' }).ok, false);
180
+ assert.strictEqual(validateRoleDefinitionShape('coder', { provider: 'codex', model: 'gpt-5.6;echo-injected' }).ok, false);
181
+ assert.strictEqual(validateRoleDefinitionShape('reviewer', { provider: 'claude', model: 'claude-opus-4-7', modelFamily: 'openai-gpt' }).ok, false);
182
+ assert.strictEqual(validateRoleDefinitionShape('coder', { provider: 'codex', primary: { provider: 'claude' } }).ok, false);
183
+ });
184
+
185
+ test('schema v1 role definition remains readable', () => {
186
+ const r = normalizeRoleDefinition('coder', { provider: 'codex', model: 'm1', persona: 'worker' });
187
+ assert.deepStrictEqual(r.primary, { provider: 'codex', model: 'm1', modelFamily: null, source: 'primary' });
188
+ assert.strictEqual(r.fallbackPolicy, 'balanced');
189
+ assert.strictEqual(r.persona, 'worker');
190
+ assert.ok(r.requirements.coding);
191
+ });
192
+
193
+ test('schema v2 primary/candidates/policy are normalized and deduplicated', () => {
194
+ const r = normalizeRoleDefinition('reviewer', {
195
+ primary: { provider: 'claude', model: 'a' },
196
+ candidates: [
197
+ { provider: 'claude', model: 'a' },
198
+ { provider: 'codex', model: 'b' },
199
+ 'grok:c',
200
+ ],
201
+ fallbackPolicy: 'strict',
202
+ });
203
+ assert.strictEqual(r.primary.provider, 'claude');
204
+ assert.strictEqual(r.candidates.length, 2);
205
+ assert.strictEqual(r.fallbackPolicy, 'strict');
206
+ assert.ok(r.requirements.independence);
207
+ });
208
+
209
+ test('stored candidate fields cannot forge resolver provenance labels', () => {
210
+ const r = normalizeRoleDefinition('coder', {
211
+ primary: { provider: 'codex', model: 'gpt-5.6', source: 'catalog' },
212
+ candidates: [{ provider: 'claude', model: 'claude-sonnet-4-7', source: 'primary' }],
213
+ });
214
+ assert.strictEqual(r.primary.source, 'primary');
215
+ assert.strictEqual(r.candidates[0].source, 'configured');
216
+ });
217
+
218
+ test('authentication denial from either recorded or live evidence dominates stale positive or unknown evidence', () => {
219
+ const def = provider('codex', 'Codex');
220
+ for (const probeAuth of ['ok', 'unknown']) {
221
+ const availability = normalizeAvailability(def, { status: 'ready', installed: true, enabled: true, auth: probeAuth }, { authenticated: 'no' });
222
+ assert.strictEqual(availability.authenticated, 'no');
223
+ assert.strictEqual(availability.eligible, false);
224
+ assert.ok(availability.blockingReasons.includes('not-authenticated'));
225
+ }
226
+ for (const recordedAuth of ['ok', 'unknown']) {
227
+ const availability = normalizeAvailability(def, { status: 'ready', installed: true, enabled: true, auth: 'no' }, { authenticated: recordedAuth });
228
+ assert.strictEqual(availability.authenticated, 'no');
229
+ assert.strictEqual(availability.eligible, false);
230
+ assert.ok(availability.blockingReasons.includes('not-authenticated'));
231
+ }
232
+ });
233
+
234
+ test('catalog fallback keeps model null instead of inventing a static model ID from provider readiness', () => {
235
+ const providers = [provider('dead', 'D'), provider('codex', 'C')];
236
+ const r = resolveRoleFallback({
237
+ role: 'coder',
238
+ roleDefinition: { primary: { provider: 'dead', model: 'explicit-primary' }, fallbackPolicy: 'balanced' },
239
+ providers,
240
+ tier: 'normal',
241
+ checkProvider: checkFor(new Set(['codex'])),
242
+ });
243
+ const catalog = r.options.find(o => o.executor && o.executor.provider === 'codex');
244
+ assert.ok(catalog);
245
+ assert.strictEqual(catalog.executor.model, null);
246
+ assert.strictEqual(catalog.availability.modelEntitled, 'unknown');
247
+ assert.strictEqual(r.decision.recommendedOptionId, catalog.id);
248
+ });
249
+
250
+ test('availability keeps installation, opt-in, auth, entitlement, quota and policy as separate axes', () => {
251
+ const a = normalizeAvailability(provider('codex', 'X'), {
252
+ status: 'ready', installed: true, enabled: true, auth: 'unknown', version: '1',
253
+ });
254
+ assert.strictEqual(a.eligible, true);
255
+ assert.strictEqual(a.authenticated, 'unknown');
256
+ assert.strictEqual(a.modelEntitled, 'unknown');
257
+ assert.strictEqual(a.quota, 'unknown');
258
+ assert.strictEqual(a.policyAllowed, 'unknown');
259
+ assert.strictEqual(a.rateLimited, null);
260
+ assert.ok(a.warningReasons.includes('authentication-unverified'));
261
+
262
+ const blocked = normalizeAvailability(provider('codex', 'X'), {
263
+ status: 'ready', installed: true, enabled: true, auth: 'no',
264
+ }, { quota: 'exhausted' });
265
+ assert.strictEqual(blocked.eligible, false);
266
+ assert.ok(blocked.blockingReasons.includes('not-authenticated'));
267
+ assert.ok(blocked.blockingReasons.includes('quota-exhausted'));
268
+ });
269
+
270
+ test('availability preserves unknown/false rate-limit states and accepts safe token-like model IDs', () => {
271
+ const unknown = availabilityExtrasForCandidate({
272
+ observations: [{ provider: 'gateway', model: 'sk-aaaaaaaaaaa', quota: 'available', ledgerOrdinal: 1 }],
273
+ }, { provider: 'gateway', model: 'sk-aaaaaaaaaaa' });
274
+ const explicitFalse = availabilityExtrasForCandidate({
275
+ observations: [{ provider: 'gateway', model: 'sk-aaaaaaaaaaa', rateLimited: false, ledgerOrdinal: 1 }],
276
+ }, { provider: 'gateway', model: 'sk-aaaaaaaaaaa' });
277
+ assert.strictEqual(unknown.rateLimited, null);
278
+ assert.strictEqual(explicitFalse.rateLimited, false);
279
+ assert.strictEqual(normalizeAvailability(provider('gateway', 'X'), {
280
+ status: 'ready', installed: true, enabled: true, auth: 'ok',
281
+ }, unknown).rateLimited, null);
282
+ assert.strictEqual(normalizeAvailabilityObservation({
283
+ provider: 'gateway', model: 'sk-aaaaaaaaaaa', quota: 'available',
284
+ }).model, 'sk-aaaaaaaaaaa');
285
+ });
286
+
287
+ test('append-only availability observations block quota and clear without mutating prior evidence', () => {
288
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-availability-ledger-'));
289
+ try {
290
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
291
+ appendAvailabilityObservation(dir, { provider: 'codex', model: 'm1', reason: 'quota-exhausted', ttlMin: 10 });
292
+ let state = readAvailabilityObservations(dir);
293
+ assert.strictEqual(state.activeCount, 1);
294
+ let extra = availabilityExtrasForCandidate(state, { provider: 'codex', model: 'm1' });
295
+ assert.strictEqual(extra.quota, 'exhausted');
296
+ assert.strictEqual(extra.observationReason, 'quota-exhausted');
297
+ appendAvailabilityClear(dir, { provider: 'codex', model: 'm1' });
298
+ state = readAvailabilityObservations(dir);
299
+ assert.strictEqual(state.activeCount, 0);
300
+ const history = readExecutionEvents(dir, 20);
301
+ assert.ok(history.events.some(e => e.event === 'availability.observed'));
302
+ assert.ok(history.events.some(e => e.event === 'availability.cleared'));
303
+ } finally {
304
+ fs.rmSync(dir, { recursive: true, force: true });
305
+ }
306
+ });
307
+
308
+ test('provider aliases share one availability identity and spoofed spellings poison the ledger closed', () => {
309
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-availability-alias-'));
310
+ try {
311
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
312
+ appendAvailabilityObservation(dir, { provider: 'LIVE', model: 'm1', reason: 'quota-exhausted' });
313
+ let state = readAvailabilityObservations(dir);
314
+ const extra = availabilityExtrasForCandidate(state, { provider: 'live', model: 'm1' });
315
+ assert.strictEqual(state.ok, true, state.error);
316
+ assert.strictEqual(state.observations[0].provider, 'live');
317
+ assert.strictEqual(extra.quota, 'exhausted');
318
+
319
+ const poisoned = {
320
+ schemaVersion: 1,
321
+ event: 'availability.observed',
322
+ eventId: 'evt-provider-spoof',
323
+ at: new Date().toISOString(),
324
+ actualExecutor: { provider: 'live' },
325
+ availability: { provider: 'li\u200bve', quota: 'available', observedAt: new Date().toISOString() },
326
+ };
327
+ fs.appendFileSync(path.join(dir, '.leerness', 'execution-ledger.jsonl'), JSON.stringify(poisoned) + '\n', 'utf8');
328
+ state = readAvailabilityObservations(dir);
329
+ assert.strictEqual(state.ok, false);
330
+ assert.strictEqual(state.code, 'availability_events_invalid');
331
+ assert.strictEqual(state.partial, true);
332
+ } finally {
333
+ fs.rmSync(dir, { recursive: true, force: true });
334
+ }
335
+ });
336
+
337
+ test('separate exact-model observations preserve independent axes and latest value per axis', () => {
338
+ const state = {
339
+ observations: [
340
+ { provider: 'gateway', model: 'm1', policyAllowed: 'denied', reason: 'policy-denied', eventId: 'e1' },
341
+ { provider: 'gateway', model: 'm1', quota: 'exhausted', reason: 'quota-exhausted', eventId: 'e2' },
342
+ { provider: 'gateway', model: 'm1', quota: 'available', reason: 'quota-restored', eventId: 'e3' },
343
+ ],
344
+ };
345
+ const extra = availabilityExtrasForCandidate(state, { provider: 'gateway', model: 'm1' });
346
+ assert.strictEqual(extra.policyAllowed, 'denied');
347
+ assert.strictEqual(extra.quota, 'available');
348
+ assert.deepStrictEqual(extra.observationEventId, ['e1', 'e2', 'e3']);
349
+ });
350
+
351
+ test('provider-wide denial is retained when an exact-model observation supplies another axis', () => {
352
+ const state = {
353
+ observations: [
354
+ { provider: 'gateway', model: null, policyAllowed: 'denied', reason: 'policy-denied', eventId: 'wide' },
355
+ { provider: 'gateway', model: 'm1', quota: 'available', reason: 'quota-confirmed', eventId: 'exact' },
356
+ ],
357
+ };
358
+ const extra = availabilityExtrasForCandidate(state, { provider: 'gateway', model: 'm1' });
359
+ assert.strictEqual(extra.policyAllowed, 'denied');
360
+ assert.strictEqual(extra.quota, 'available');
361
+ assert.deepStrictEqual(extra.observationEventId, ['wide', 'exact']);
362
+ });
363
+
364
+ test('newer provider-wide policy denial overrides an older exact-model allow', () => {
365
+ const state = {
366
+ observations: [
367
+ { provider: 'gateway', model: 'm1', policyAllowed: 'allowed', reason: 'old-exact-allow', eventId: 'exact-old', ledgerOrdinal: 1 },
368
+ { provider: 'gateway', model: null, policyAllowed: 'denied', reason: 'new-wide-denial', eventId: 'wide-new', ledgerOrdinal: 2 },
369
+ ],
370
+ };
371
+ const extra = availabilityExtrasForCandidate(state, { provider: 'gateway', model: 'm1' });
372
+ assert.strictEqual(extra.policyAllowed, 'denied');
373
+ assert.match(extra.observationReason, /new-wide-denial/);
374
+ assert.deepStrictEqual(extra.observationEventId, ['exact-old', 'wide-new']);
375
+ });
376
+
377
+ test('availability validation and append share one lock boundary', () => {
378
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-availability-lock-'));
379
+ try {
380
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
381
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
382
+ const withLock = (_file, fn) => {
383
+ fs.writeFileSync(ledger, '{ race-corrupt\n');
384
+ return fn();
385
+ };
386
+ assert.throws(
387
+ () => appendAvailabilityObservation(dir, { provider: 'codex', reason: 'quota-exhausted' }, { withLock }),
388
+ error => error && error.code === 'ledger_invalid_lines',
389
+ );
390
+ assert.strictEqual(fs.readFileSync(ledger, 'utf8'), '{ race-corrupt\n');
391
+ } finally {
392
+ fs.rmSync(dir, { recursive: true, force: true });
393
+ }
394
+ });
395
+
396
+ test('availability mark and clear refuse a corrupt ledger without changing its bytes', () => {
397
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-availability-corrupt-'));
398
+ try {
399
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
400
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
401
+ fs.writeFileSync(ledger, '{ broken\n');
402
+ const before = fs.readFileSync(ledger);
403
+ assert.throws(() => appendAvailabilityObservation(dir, { provider: 'codex', reason: 'quota-exhausted' }), error => error && error.code === 'ledger_invalid_lines');
404
+ assert.throws(() => appendAvailabilityClear(dir, { provider: 'codex' }), error => error && error.code === 'ledger_invalid_lines');
405
+ assert.deepStrictEqual(fs.readFileSync(ledger), before);
406
+ } finally {
407
+ fs.rmSync(dir, { recursive: true, force: true });
408
+ }
409
+ });
410
+
411
+ test('semantically invalid availability events make routing state partial and block later appends', () => {
412
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-availability-semantic-corrupt-'));
413
+ try {
414
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
415
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
416
+ const malformed = {
417
+ schemaVersion: 1,
418
+ event: 'availability.observed',
419
+ eventId: 'evt-invalid-availability',
420
+ at: new Date().toISOString(),
421
+ actualExecutor: { provider: 'codex' },
422
+ availability: { provider: 'codex', quota: 'plenty' },
423
+ };
424
+ fs.writeFileSync(ledger, JSON.stringify(malformed) + '\n', 'utf8');
425
+ const before = fs.readFileSync(ledger);
426
+ const state = readAvailabilityObservations(dir, 2000);
427
+ assert.strictEqual(state.ok, false);
428
+ assert.strictEqual(state.partial, true);
429
+ assert.strictEqual(state.code, 'availability_events_invalid');
430
+ assert.strictEqual(state.invalidAvailabilityEvents, 1);
431
+ assert.throws(() => appendAvailabilityClear(dir, { provider: 'codex' }), error => error && error.code === 'availability_events_invalid');
432
+ assert.deepStrictEqual(fs.readFileSync(ledger), before);
433
+ } finally {
434
+ fs.rmSync(dir, { recursive: true, force: true });
435
+ }
436
+ });
437
+
438
+ test('provider-only fallback rejects multiple selectable models and chooses a unique selectable model', () => {
439
+ const providers = [provider('p', 'P')];
440
+ const ready = resolveRoleFallback({
441
+ role: 'coder',
442
+ roleDefinition: { primary: { provider: 'p', model: 'm1' }, candidates: [{ provider: 'p', model: 'm2' }], fallbackPolicy: 'balanced' },
443
+ providers,
444
+ checkProvider: checkFor(new Set(['p'])),
445
+ });
446
+ let selected = selectFallbackOption(ready, { choice: 'provider', provider: 'p' });
447
+ assert.strictEqual(selected.ok, false);
448
+ assert.strictEqual(selected.code, 'fallback_choice_ambiguous');
449
+ const blockedPrimary = resolveRoleFallback({
450
+ role: 'coder',
451
+ roleDefinition: { primary: { provider: 'p', model: 'm1' }, candidates: [{ provider: 'p', model: 'm2' }], fallbackPolicy: 'balanced' },
452
+ providers,
453
+ checkProvider: checkFor(new Set(['p'])),
454
+ availabilityExtras: candidate => candidate.model === 'm1' ? { quota: 'exhausted' } : {},
455
+ });
456
+ selected = selectFallbackOption(blockedPrimary, { choice: 'provider', provider: 'p' });
457
+ assert.strictEqual(selected.ok, true);
458
+ assert.strictEqual(selected.option.executor.model, 'm2');
459
+ });
460
+
461
+ test('balanced policy recommends a ready compatible provider without auto-selecting it', () => {
462
+ const providers = [provider('primary', 'P'), provider('backup', 'B')];
463
+ const r = resolveRoleFallback({
464
+ role: 'coder',
465
+ roleDefinition: {
466
+ primary: { provider: 'primary', model: 'p' },
467
+ candidates: [{ provider: 'backup', model: 'b' }],
468
+ fallbackPolicy: 'balanced',
469
+ },
470
+ providers,
471
+ tier: 'normal',
472
+ checkProvider: checkFor(new Set(['backup'])),
473
+ sessionIdentity: { provider: 'host', model: 'session-model', identitySource: 'host-reported' },
474
+ });
475
+ assert.strictEqual(r.decision.primaryReady, false);
476
+ assert.strictEqual(r.decision.autoSelected, false);
477
+ assert.match(r.decision.recommendedOptionId, /^compatible-provider:/);
478
+ const backup = r.options.find(o => o.executor && o.executor.provider === 'backup');
479
+ assert.strictEqual(backup.selectable, true);
480
+ assert.strictEqual(backup.requiresConfirmation, true);
481
+ });
482
+
483
+ test('strict policy exposes hold and blocks non-primary provider substitution', () => {
484
+ const providers = [provider('primary', 'P'), provider('backup', 'B')];
485
+ const r = resolveRoleFallback({
486
+ role: 'coder',
487
+ roleDefinition: {
488
+ primary: { provider: 'primary' },
489
+ candidates: [{ provider: 'backup' }],
490
+ fallbackPolicy: 'strict',
491
+ },
492
+ providers,
493
+ tier: 'normal',
494
+ checkProvider: checkFor(new Set(['backup'])),
495
+ });
496
+ assert.strictEqual(r.decision.recommendedOptionId, 'hold');
497
+ assert.strictEqual(r.options.some(o => o.kind === 'session-takeover'), false);
498
+ assert.strictEqual(r.options.find(o => o.executor && o.executor.provider === 'backup').selectable, false);
499
+ });
500
+
501
+ test('incomplete current-session identity is visible but blocked and never recommended', () => {
502
+ const r = resolveRoleFallback({
503
+ role: 'coder',
504
+ roleDefinition: { primary: { provider: 'primary' }, fallbackPolicy: 'continuity' },
505
+ providers: [provider('primary', 'P')],
506
+ tier: 'normal',
507
+ checkProvider: checkFor(new Set()),
508
+ sessionIdentity: { provider: null, model: null, identitySource: 'unknown' },
509
+ });
510
+ assert.strictEqual(r.options.find(o => o.id === 'session-takeover').selectable, false);
511
+ assert.strictEqual(r.options.find(o => o.id === 'session-direct').selectable, false);
512
+ assert.strictEqual(r.decision.recommendedOptionId, 'hold');
513
+ });
514
+
515
+ test('continuity policy can recommend current-session takeover when no provider candidate is ready', () => {
516
+ const r = resolveRoleFallback({
517
+ role: 'coder',
518
+ roleDefinition: { primary: { provider: 'primary' }, fallbackPolicy: 'continuity' },
519
+ providers: [provider('primary', 'P')],
520
+ tier: 'normal',
521
+ checkProvider: checkFor(new Set()),
522
+ sessionIdentity: { provider: 'chat-host', model: 'model-x', identitySource: 'host-reported' },
523
+ });
524
+ assert.strictEqual(r.decision.recommendedOptionId, 'session-takeover');
525
+ assert.strictEqual(r.options.find(o => o.id === 'session-takeover').contract.externalModelCalledByLeerness, false);
526
+ });
527
+
528
+ test('high-risk substitution requires explicit human approval and defaults to hold', () => {
529
+ const providers = [provider('primary', 'P'), provider('backup', 'B')];
530
+ const r = resolveRoleFallback({
531
+ role: 'coder',
532
+ roleDefinition: {
533
+ primary: { provider: 'primary' },
534
+ candidates: [{ provider: 'backup' }],
535
+ fallbackPolicy: 'balanced',
536
+ },
537
+ providers,
538
+ tier: 'high-risk',
539
+ checkProvider: checkFor(new Set(['backup'])),
540
+ });
541
+ assert.strictEqual(r.decision.recommendedOptionId, 'hold');
542
+ const denied = selectFallbackOption(r, { choice: 'provider', provider: 'backup' });
543
+ assert.strictEqual(denied.code, 'human_approval_required');
544
+ const approved = selectFallbackOption(r, { choice: 'provider', provider: 'backup', approvedBy: 'owner' });
545
+ assert.strictEqual(approved.ok, true);
546
+ for (const invisible of ['.', '!!!', '\u034f', '\u115f', '\u1160', '\u200b', '\u200d', '\u2060', '\u2063', '\u2800', '\u3164', '\uffa0']) {
547
+ const bypass = selectFallbackOption(r, { choice: 'provider', provider: 'backup', approvedBy: invisible });
548
+ assert.strictEqual(bypass.code, 'human_approval_required');
549
+ }
550
+ });
551
+
552
+ test('session-direct can never satisfy high-risk independent review even with a different model family', () => {
553
+ const r = resolveRoleFallback({
554
+ role: 'reviewer',
555
+ roleDefinition: { fallbackPolicy: 'continuity' },
556
+ tier: 'high-risk',
557
+ providers: [],
558
+ implementerIdentity: { provider: 'a', model: 'claude-opus', modelFamily: 'claude', sessionId: 'implementation' },
559
+ sessionIdentity: { provider: 'openai', model: 'gpt-5.6', modelFamily: 'openai-gpt', sessionId: 'review-host', identitySource: 'host-reported' },
560
+ });
561
+ const takeover = r.options.find(o => o.id === 'session-takeover');
562
+ const direct = r.options.find(o => o.id === 'session-direct');
563
+ assert.strictEqual(takeover.selectable, true);
564
+ assert.strictEqual(takeover.reviewerIndependent, true);
565
+ assert.strictEqual(direct.selectable, false);
566
+ assert.strictEqual(direct.reviewerIndependent, false);
567
+ assert.strictEqual(direct.independenceBasis, 'session-direct-self-review');
568
+ });
569
+
570
+ test('high-risk reviewer fallback is blocked until distinct providers and model families are proven', () => {
571
+ const providers = [provider('primary', 'P'), provider('backup', 'B')];
572
+ let r = resolveRoleFallback({
573
+ role: 'reviewer',
574
+ roleDefinition: {
575
+ primary: { provider: 'primary', model: 'claude-sonnet-4-7', modelFamily: 'claude' },
576
+ candidates: [{ provider: 'backup', model: 'claude-opus-4-7', modelFamily: 'claude' }],
577
+ fallbackPolicy: 'balanced',
578
+ },
579
+ providers,
580
+ tier: 'high-risk',
581
+ checkProvider: checkFor(new Set(['backup'])),
582
+ implementerIdentity: { provider: 'worker', model: 'claude-sonnet-4-7', modelFamily: 'claude' },
583
+ });
584
+ const sameFamily = r.options.find(o => o.executor && o.executor.provider === 'backup');
585
+ assert.strictEqual(sameFamily.selectable, false);
586
+ assert.strictEqual(sameFamily.reviewerIndependence, 'not-independent');
587
+ assert.strictEqual(r.decision.recommendedOptionId, 'hold');
588
+
589
+ r = resolveRoleFallback({
590
+ role: 'reviewer',
591
+ roleDefinition: {
592
+ primary: { provider: 'primary', model: 'claude-sonnet-4-7', modelFamily: 'claude' },
593
+ candidates: [{ provider: 'backup', model: null, modelFamily: 'openai-gpt' }],
594
+ fallbackPolicy: 'balanced',
595
+ },
596
+ providers,
597
+ tier: 'high-risk',
598
+ checkProvider: checkFor(new Set(['backup'])),
599
+ implementerIdentity: { provider: 'worker', model: 'claude-sonnet-4-7', modelFamily: 'claude' },
600
+ });
601
+ const familyOnly = r.options.find(o => o.executor && o.executor.provider === 'backup');
602
+ assert.strictEqual(familyOnly.selectable, false);
603
+ assert.strictEqual(familyOnly.reviewerIndependence, 'unverified');
604
+
605
+ r = resolveRoleFallback({
606
+ role: 'reviewer',
607
+ roleDefinition: {
608
+ primary: { provider: 'primary', model: 'claude-sonnet-4-7', modelFamily: 'claude' },
609
+ candidates: [{ provider: 'backup', model: 'gpt-5.6', modelFamily: 'openai-gpt' }],
610
+ fallbackPolicy: 'balanced',
611
+ },
612
+ providers,
613
+ tier: 'high-risk',
614
+ checkProvider: checkFor(new Set(['backup'])),
615
+ implementerIdentity: { provider: 'worker', model: 'claude-sonnet-4-7', modelFamily: 'claude' },
616
+ });
617
+ const differentFamily = r.options.find(o => o.executor && o.executor.provider === 'backup');
618
+ assert.strictEqual(differentFamily.selectable, true);
619
+ assert.strictEqual(differentFamily.reviewerIndependence, 'independent');
620
+ const approved = selectFallbackOption(r, { choice: 'provider', provider: 'backup', approvedBy: 'owner' });
621
+ assert.strictEqual(approved.ok, true);
622
+ });
623
+
624
+ test('all execution ledger appends reject corrupt history without changing its bytes', () => {
625
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-failclosed-'));
626
+ try {
627
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
628
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
629
+ fs.writeFileSync(ledger, '{ corrupt history\n', 'utf8');
630
+ const before = fs.readFileSync(ledger);
631
+ assert.throws(
632
+ () => appendExecutionEvent(dir, { event: 'execution.completed', taskId: 'T-X', result: { summary: 'must not append' }, executed: true }, { withLock: (_file, fn) => fn() }),
633
+ error => error && error.code === 'ledger_invalid_lines',
634
+ );
635
+ assert.deepStrictEqual(fs.readFileSync(ledger), before);
636
+ } finally {
637
+ fs.rmSync(dir, { recursive: true, force: true });
638
+ }
639
+ });
640
+
641
+ test('malformed UTF-8 in the execution ledger fails availability reads and later appends closed', () => {
642
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-utf8-'));
643
+ try {
644
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
645
+ appendAvailabilityObservation(dir, {
646
+ provider: 'live', model: 'backup-model', policyAllowed: 'denied', reason: 'policy-denied',
647
+ });
648
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
649
+ const bytes = fs.readFileSync(ledger);
650
+ const marker = Buffer.from('availability.observed', 'utf8');
651
+ const markerAt = bytes.indexOf(marker);
652
+ assert.ok(markerAt >= 0);
653
+ bytes[markerAt + 5] = 0xff;
654
+ fs.writeFileSync(ledger, bytes);
655
+ const before = fs.readFileSync(ledger);
656
+ const history = readExecutionEvents(dir, 20);
657
+ const availability = readAvailabilityObservations(dir, 20);
658
+ assert.strictEqual(history.ok, false);
659
+ assert.strictEqual(history.code, 'ledger_invalid_utf8');
660
+ assert.strictEqual(availability.ok, false);
661
+ assert.strictEqual(availability.code, 'ledger_invalid_utf8');
662
+ assert.deepStrictEqual(availability.observations, []);
663
+ assert.throws(
664
+ () => appendAvailabilityClear(dir, { provider: 'live', model: 'backup-model' }),
665
+ error => error && error.code === 'ledger_invalid_utf8',
666
+ );
667
+ assert.deepStrictEqual(fs.readFileSync(ledger), before);
668
+ } finally {
669
+ fs.rmSync(dir, { recursive: true, force: true });
670
+ }
671
+ });
672
+
673
+ test('linked execution-ledger parents are rejected before any external target is touched', () => {
674
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-parent-link-'));
675
+ const dir = path.join(base, 'project');
676
+ const outside = path.join(base, 'outside');
677
+ const harness = path.join(dir, '.leerness');
678
+ try {
679
+ fs.mkdirSync(dir);
680
+ fs.mkdirSync(outside);
681
+ fs.writeFileSync(path.join(outside, 'sentinel.txt'), 'unchanged\n', 'utf8');
682
+ fs.symlinkSync(outside, harness, process.platform === 'win32' ? 'junction' : 'dir');
683
+ const before = fs.readFileSync(path.join(outside, 'sentinel.txt'));
684
+ const history = readExecutionEvents(dir, 20);
685
+ assert.strictEqual(history.ok, false);
686
+ assert.strictEqual(history.code, 'ledger_parent_linked');
687
+ assert.throws(
688
+ () => appendExecutionEvent(dir, { event: 'execution.started', taskId: 'T-LINK' }),
689
+ error => error && error.code === 'ledger_parent_linked',
690
+ );
691
+ assert.strictEqual(fs.existsSync(path.join(outside, 'execution-ledger.jsonl')), false);
692
+ assert.deepStrictEqual(fs.readFileSync(path.join(outside, 'sentinel.txt')), before);
693
+ } finally {
694
+ try { fs.rmSync(harness, { force: true }); } catch {}
695
+ fs.rmSync(base, { recursive: true, force: true });
696
+ }
697
+ });
698
+
699
+ test('execution-ledger reads and appends bind validation to the opened inode', () => {
700
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-inode-race-'));
701
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
702
+ const moved = path.join(dir, 'moved-ledger.jsonl');
703
+ try {
704
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
705
+ appendExecutionEvent(dir, { event: 'execution.started', taskId: 'T-INODE', attemptId: 'start-inode', executed: true });
706
+ const replacement = JSON.stringify(normalizeExecutionEvent({
707
+ schemaVersion: 1, event: 'execution.started', eventId: 'evt-replacement',
708
+ at: new Date().toISOString(), taskId: 'T-OUTSIDE', attemptId: 'outside', executed: true,
709
+ })) + '\n';
710
+ const originalOpen = fs.openSync;
711
+ let readSwapped = false;
712
+ try {
713
+ fs.openSync = function swappedRead(target, flags, ...rest) {
714
+ if (!readSwapped && target === ledger && (flags === 'r' || (typeof flags === 'number' && (flags & fs.constants.O_WRONLY) === 0))) {
715
+ readSwapped = true;
716
+ fs.renameSync(ledger, moved);
717
+ fs.writeFileSync(ledger, replacement, 'utf8');
718
+ }
719
+ return originalOpen.call(fs, target, flags, ...rest);
720
+ };
721
+ const history = readExecutionEvents(dir, 20);
722
+ assert.strictEqual(readSwapped, true);
723
+ assert.strictEqual(history.ok, false);
724
+ assert.strictEqual(history.code, 'ledger_file_changed');
725
+ } finally { fs.openSync = originalOpen; }
726
+
727
+ fs.rmSync(ledger, { force: true });
728
+ fs.renameSync(moved, ledger);
729
+ const replacementBytes = Buffer.from(replacement);
730
+ let writeSwapped = false;
731
+ try {
732
+ fs.openSync = function swappedWrite(target, flags, ...rest) {
733
+ if (!writeSwapped && target === ledger && typeof flags === 'number' && (flags & fs.constants.O_WRONLY) !== 0) {
734
+ writeSwapped = true;
735
+ fs.renameSync(ledger, moved);
736
+ fs.writeFileSync(ledger, replacementBytes);
737
+ }
738
+ return originalOpen.call(fs, target, flags, ...rest);
739
+ };
740
+ assert.throws(
741
+ () => appendExecutionEvent(dir, { event: 'execution.started', taskId: 'T-INODE', attemptId: 'second', executed: true }),
742
+ error => error && error.code === 'ledger_file_changed',
743
+ );
744
+ assert.strictEqual(writeSwapped, true);
745
+ assert.deepStrictEqual(fs.readFileSync(ledger), replacementBytes);
746
+ } finally { fs.openSync = originalOpen; }
747
+ } finally {
748
+ fs.rmSync(dir, { recursive: true, force: true });
749
+ }
750
+ });
751
+
752
+ test('dangling execution-ledger symlinks are rejected without creating their target', () => {
753
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-dangling-'));
754
+ const dir = path.join(base, 'project');
755
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
756
+ const missingTarget = path.join(base, 'outside-missing.jsonl');
757
+ try {
758
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
759
+ try { fs.symlinkSync(missingTarget, ledger, 'file'); }
760
+ catch { return; }
761
+ const history = readExecutionEvents(dir, 20);
762
+ assert.strictEqual(history.ok, false);
763
+ assert.strictEqual(history.code, 'ledger_not_regular_file');
764
+ assert.throws(
765
+ () => appendExecutionEvent(dir, { event: 'execution.started', taskId: 'T-DANGLING' }),
766
+ error => error && error.code === 'ledger_not_regular_file',
767
+ );
768
+ assert.strictEqual(fs.existsSync(missingTarget), false);
769
+ } finally {
770
+ try { fs.rmSync(ledger, { force: true }); } catch {}
771
+ fs.rmSync(base, { recursive: true, force: true });
772
+ }
773
+ });
774
+
775
+ test('hard-linked execution ledgers fail closed for reads and appends without touching the peer file', () => {
776
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-hardlink-'));
777
+ try {
778
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
779
+ const peer = path.join(dir, 'peer-ledger.jsonl');
780
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
781
+ fs.writeFileSync(peer, '', 'utf8');
782
+ let linked = false;
783
+ try { fs.linkSync(peer, ledger); linked = true; } catch {}
784
+ if (!linked) return;
785
+ const before = fs.readFileSync(peer);
786
+ const history = readExecutionEvents(dir, 20);
787
+ assert.strictEqual(history.ok, false);
788
+ assert.strictEqual(history.code, 'ledger_hard_link_rejected');
789
+ assert.throws(
790
+ () => appendExecutionEvent(dir, { event: 'execution.completed', taskId: 'T-LINKED' }),
791
+ error => error && error.code === 'ledger_hard_link_rejected',
792
+ );
793
+ assert.deepStrictEqual(fs.readFileSync(peer), before);
794
+ } finally {
795
+ fs.rmSync(dir, { recursive: true, force: true });
796
+ }
797
+ });
798
+
799
+ test('initial execution-ledger installation survives prepared-alias cleanup failure without poisoning the live inode', () => {
800
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-create-cleanup-'));
801
+ const harness = path.join(dir, '.leerness');
802
+ const ledger = path.join(harness, 'execution-ledger.jsonl');
803
+ const originalUnlink = fs.unlinkSync;
804
+ const originalRm = fs.rmSync;
805
+ let cleanupInjected = false;
806
+ let appendError = null;
807
+ const isCreateAlias = target => path.dirname(String(target)) === harness
808
+ && path.basename(String(target)).startsWith('.execution-ledger-create-')
809
+ && !String(target).includes('.detach-');
810
+ try {
811
+ fs.mkdirSync(harness, { recursive: true });
812
+ fs.unlinkSync = function blockedLedgerAliasUnlink(target, ...args) {
813
+ if (isCreateAlias(target)) {
814
+ cleanupInjected = true;
815
+ throw Object.assign(new Error('injected ledger-alias unlink failure'), { code: 'EACCES' });
816
+ }
817
+ return originalUnlink.call(fs, target, ...args);
818
+ };
819
+ fs.rmSync = function blockedLedgerAliasRm(target, ...args) {
820
+ if (process.platform !== 'win32' && isCreateAlias(target)) {
821
+ throw Object.assign(new Error('injected ledger-alias rm failure'), { code: 'EACCES' });
822
+ }
823
+ return originalRm.call(fs, target, ...args);
824
+ };
825
+ appendExecutionEvent(dir, {
826
+ event: 'execution.started', taskId: 'T-CREATE-CLEANUP', attemptId: 'impl-create-cleanup', executed: true,
827
+ });
828
+ } catch (error) { appendError = error; }
829
+ finally {
830
+ fs.unlinkSync = originalUnlink;
831
+ fs.rmSync = originalRm;
832
+ }
833
+ try {
834
+ const history = readExecutionEvents(dir, 20);
835
+ assert.strictEqual(cleanupInjected, true);
836
+ assert.strictEqual(appendError, null, appendError && appendError.stack);
837
+ assert.strictEqual(Number(fs.lstatSync(ledger).nlink), 1);
838
+ assert.strictEqual(history.ok, true, history.error);
839
+ assert.strictEqual(history.events.length, 1);
840
+ assert.strictEqual(history.events[0].taskId, 'T-CREATE-CLEANUP');
841
+ } finally {
842
+ fs.rmSync(dir, { recursive: true, force: true });
843
+ }
844
+ });
845
+
846
+ test('initial execution-ledger installation rolls back its canonical path when every alias detach method fails', () => {
847
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-create-rollback-'));
848
+ const harness = path.join(dir, '.leerness');
849
+ const ledger = path.join(harness, 'execution-ledger.jsonl');
850
+ const originalUnlink = fs.unlinkSync;
851
+ const originalRm = fs.rmSync;
852
+ const originalRename = fs.renameSync;
853
+ let renameFailureInjected = false;
854
+ let appendError = null;
855
+ const isCreateAlias = target => path.dirname(String(target)) === harness
856
+ && path.basename(String(target)).startsWith('.execution-ledger-create-')
857
+ && !String(target).includes('.detach-');
858
+ try {
859
+ fs.mkdirSync(harness, { recursive: true });
860
+ fs.unlinkSync = function blockedLedgerRollbackUnlink(target, ...args) {
861
+ if (isCreateAlias(target)) throw Object.assign(new Error('injected ledger rollback unlink failure'), { code: 'EACCES' });
862
+ return originalUnlink.call(fs, target, ...args);
863
+ };
864
+ fs.rmSync = function blockedLedgerRollbackRm(target, ...args) {
865
+ if (isCreateAlias(target)) throw Object.assign(new Error('injected ledger rollback rm failure'), { code: 'EACCES' });
866
+ return originalRm.call(fs, target, ...args);
867
+ };
868
+ fs.renameSync = function blockedLedgerRollbackRename(from, to, ...args) {
869
+ if (String(from).includes('.detach-') && isCreateAlias(to)) {
870
+ renameFailureInjected = true;
871
+ throw Object.assign(new Error('injected ledger rollback rename failure'), { code: 'EACCES' });
872
+ }
873
+ return originalRename.call(fs, from, to, ...args);
874
+ };
875
+ appendExecutionEvent(dir, {
876
+ event: 'execution.started', taskId: 'T-CREATE-ROLLBACK', attemptId: 'impl-create-rollback', executed: true,
877
+ });
878
+ } catch (error) { appendError = error; }
879
+ finally {
880
+ fs.unlinkSync = originalUnlink;
881
+ fs.rmSync = originalRm;
882
+ fs.renameSync = originalRename;
883
+ }
884
+ try {
885
+ const artifacts = fs.readdirSync(harness)
886
+ .map(name => path.join(harness, name))
887
+ .filter(file => fs.lstatSync(file).isFile());
888
+ assert.strictEqual(renameFailureInjected, true);
889
+ assert.ok(appendError && appendError.code === 'E_HARDLINK_DETACH_FAILED', appendError && appendError.stack);
890
+ assert.strictEqual(appendError.canonicalRolledBack, true);
891
+ assert.strictEqual(fs.existsSync(ledger), false);
892
+ assert.ok(artifacts.every(file => Number(fs.lstatSync(file).nlink) === 1));
893
+ const history = readExecutionEvents(dir, 20);
894
+ assert.strictEqual(history.ok, true);
895
+ assert.deepStrictEqual(history.events, []);
896
+ } finally {
897
+ fs.rmSync(dir, { recursive: true, force: true });
898
+ }
899
+ });
900
+
901
+ test('execution ledger preserves its canonical envelope when extension fields exceed the object bound', () => {
902
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-envelope-'));
903
+ try {
904
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
905
+ const extensions = {};
906
+ for (let i = 0; i < 150; i++) extensions[`extension${i}`] = `value-${i}`;
907
+ const written = appendExecutionEvent(dir, {
908
+ ...extensions,
909
+ event: 'execution.completed',
910
+ taskId: 'T-ENVELOPE',
911
+ attemptId: 'impl-envelope',
912
+ parentAttemptId: 'route-envelope',
913
+ requestedRole: 'coder',
914
+ actualExecutor: { provider: 'synthetic', model: 'model-x' },
915
+ result: { summary: 'bounded extension payload' },
916
+ evidenceRefs: ['envelope-evidence'],
917
+ executed: true,
918
+ });
919
+ assert.strictEqual(written.event.schemaVersion, 1);
920
+ assert.strictEqual(written.event.event, 'execution.completed');
921
+ assert.ok(written.event.eventId);
922
+ assert.ok(written.event.at);
923
+ assert.strictEqual(written.event.taskId, 'T-ENVELOPE');
924
+ assert.strictEqual(written.event.executed, true);
925
+ const history = readExecutionEvents(dir, 10);
926
+ assert.strictEqual(history.ok, true, history.error);
927
+ assert.strictEqual(history.events.length, 1);
928
+ } finally {
929
+ fs.rmSync(dir, { recursive: true, force: true });
930
+ }
931
+ });
932
+
933
+ test('token-shaped canonical provenance IDs remain readable after a successful append', () => {
934
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-token-id-'));
935
+ try {
936
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
937
+ appendExecutionEvent(dir, {
938
+ event: 'execution.completed', taskId: 'sk-abcdefghijk', attemptId: 'impl-token-id',
939
+ actualExecutor: { provider: 'synthetic', model: 'sk-modeltoken' },
940
+ result: { summary: 'token-shaped identifiers are public provenance IDs' },
941
+ evidenceRefs: ['token-id-evidence'], executed: true,
942
+ });
943
+ const history = readExecutionEvents(dir, 10);
944
+ assert.strictEqual(history.ok, true, history.error);
945
+ assert.strictEqual(history.events[0].taskId, 'sk-abcdefghijk');
946
+ assert.strictEqual(history.events[0].actualExecutor.model, 'sk-modeltoken');
947
+ } finally {
948
+ fs.rmSync(dir, { recursive: true, force: true });
949
+ }
950
+ });
951
+
952
+ test('credential-shaped model values are rejected before ledger or availability persistence', () => {
953
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-sensitive-model-'));
954
+ const sensitiveModel = `sk-proj-${'A'.repeat(48)}`;
955
+ const fullwidthSensitiveModel = `sk-proj-${'A'.repeat(48)}`;
956
+ const sensitiveProvider = `sk-proj-${'B'.repeat(48)}`;
957
+ try {
958
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
959
+ assert.throws(
960
+ () => appendExecutionEvent(dir, {
961
+ event: 'execution.completed', taskId: 'T-SENSITIVE', attemptId: 'impl-sensitive',
962
+ actualExecutor: { provider: 'synthetic', model: sensitiveModel },
963
+ evidenceRefs: ['evidence'], executed: true,
964
+ }),
965
+ error => error && error.code === 'ledger_sensitive_identifier',
966
+ );
967
+ assert.throws(
968
+ () => normalizeAvailabilityObservation({ provider: 'synthetic', model: sensitiveModel, quota: 'available' }),
969
+ error => error && error.code === 'availability_sensitive_model',
970
+ );
971
+ assert.throws(
972
+ () => appendExecutionEvent(dir, {
973
+ event: 'execution.completed', taskId: 'T-FULLWIDTH-SENSITIVE', attemptId: 'impl-fullwidth-sensitive',
974
+ actualExecutor: { provider: 'synthetic', model: fullwidthSensitiveModel },
975
+ evidenceRefs: ['evidence'], executed: true,
976
+ }),
977
+ error => error && error.code === 'ledger_sensitive_identifier',
978
+ );
979
+ assert.throws(
980
+ () => normalizeAvailabilityObservation({ provider: 'synthetic', model: fullwidthSensitiveModel, quota: 'available' }),
981
+ error => error && error.code === 'availability_sensitive_model',
982
+ );
983
+ assert.throws(
984
+ () => appendExecutionEvent(dir, {
985
+ event: 'execution.completed', taskId: 'T-SENSITIVE-PROVIDER', attemptId: 'impl-sensitive-provider',
986
+ actualExecutor: { provider: sensitiveProvider, model: 'model-x' },
987
+ evidenceRefs: ['evidence'], executed: true,
988
+ }),
989
+ error => error && error.code === 'ledger_sensitive_identifier',
990
+ );
991
+ assert.throws(
992
+ () => normalizeAvailabilityObservation({ provider: sensitiveProvider, model: 'model-x', quota: 'available' }),
993
+ error => error && error.code === 'availability_sensitive_provider',
994
+ );
995
+ assert.strictEqual(fs.existsSync(path.join(dir, '.leerness', 'execution-ledger.jsonl')), false);
996
+ } finally {
997
+ fs.rmSync(dir, { recursive: true, force: true });
998
+ }
999
+ });
1000
+
1001
+ test('incomplete terminal rows fail both append-time and stored-history validation', () => {
1002
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-terminal-shape-'));
1003
+ try {
1004
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1005
+ assert.throws(
1006
+ () => appendExecutionEvent(dir, {
1007
+ event: 'execution.completed', taskId: 'T-INCOMPLETE', attemptId: 'impl-incomplete',
1008
+ actualExecutor: { provider: 'synthetic' }, executed: true,
1009
+ }),
1010
+ error => error && error.code === 'ledger_terminal_incomplete',
1011
+ );
1012
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
1013
+ fs.writeFileSync(ledger, JSON.stringify({
1014
+ schemaVersion: 1, event: 'execution.completed', eventId: 'evt-incomplete',
1015
+ at: new Date().toISOString(), taskId: 'T-INCOMPLETE', attemptId: 'impl-incomplete',
1016
+ actualExecutor: { provider: 'synthetic' }, evidenceRefs: [], executed: true,
1017
+ }) + '\n');
1018
+ const history = readExecutionEvents(dir, 10);
1019
+ assert.strictEqual(history.ok, false);
1020
+ assert.strictEqual(history.invalidLines, 1);
1021
+ assert.deepStrictEqual(history.events, []);
1022
+ } finally {
1023
+ fs.rmSync(dir, { recursive: true, force: true });
1024
+ }
1025
+ });
1026
+
1027
+ test('terminal execution rows require executed=true at append and read time', () => {
1028
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-executed-false-'));
1029
+ try {
1030
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1031
+ const contradictory = {
1032
+ event: 'execution.completed', taskId: 'T-FALSE', attemptId: 'impl-false',
1033
+ actualExecutor: { provider: 'synthetic' }, evidenceRefs: ['evidence'], executed: false,
1034
+ };
1035
+ assert.throws(() => appendExecutionEvent(dir, contradictory), error => error && error.code === 'ledger_terminal_incomplete');
1036
+ const ledger = path.join(dir, '.leerness', 'execution-ledger.jsonl');
1037
+ fs.writeFileSync(ledger, JSON.stringify({
1038
+ ...normalizeExecutionEvent({ ...contradictory, eventId: 'evt-false', at: new Date().toISOString() }),
1039
+ }) + '\n');
1040
+ const history = readExecutionEvents(dir, 10);
1041
+ assert.strictEqual(history.ok, false);
1042
+ assert.strictEqual(history.invalidLines, 1);
1043
+ } finally {
1044
+ fs.rmSync(dir, { recursive: true, force: true });
1045
+ }
1046
+ });
1047
+
1048
+ test('execution events expose canonical role, attempt, review and evidence fields', () => {
1049
+ const normalized = normalizeExecutionEvent({
1050
+ event: 'review.completed', taskId: 'T-1', role: 'reviewer', attemptId: 'review-2', parentAttemptId: 'impl-1',
1051
+ actualExecutor: { provider: 'reviewer', model: 'gpt-x', modelFamily: 'openai-gpt', sessionId: 'review-session' },
1052
+ review: { reviewerIndependent: true }, result: { summary: 'pass', evidence: 'reports/review.md' }, executed: true,
1053
+ });
1054
+ assert.strictEqual(normalized.requestedRole, 'reviewer');
1055
+ assert.strictEqual(normalized.sessionId, 'review-session');
1056
+ assert.strictEqual(normalized.reviewOfAttemptId, 'impl-1');
1057
+ assert.strictEqual(normalized.reviewerIndependent, true);
1058
+ assert.deepStrictEqual(normalized.evidenceRefs, ['reports/review.md']);
1059
+ });
1060
+
1061
+ test('concurrent CLI provenance appends retain every unique attempt', () => {
1062
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-concurrency-'));
1063
+ try {
1064
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1065
+ for (let i = 0; i < 8; i++) seedRoleExecutionParent(dir, 'T-CONCURRENT', `selection-${i}`);
1066
+ const inline = String.raw`
1067
+ const { spawn } = require('child_process');
1068
+ const count = 8;
1069
+ const jobs = [];
1070
+ for (let i = 0; i < count; i++) {
1071
+ jobs.push(new Promise((resolve, reject) => {
1072
+ const child = spawn(process.execPath, [process.env.PROBE_CLI, 'agents', 'record', 'completed', 'concurrent-' + i,
1073
+ '--task', 'T-CONCURRENT', '--id', 'attempt-' + i, '--role', 'coder', '--to', 'live',
1074
+ '--target', 'selection-' + i, '--evidence', 'exit-0-' + i, '--path', process.env.PROBE_ROOT, '--json'],
1075
+ { stdio: 'ignore', env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_STALE_CHECK: '1' } });
1076
+ child.on('error', reject);
1077
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error('child exit ' + code)));
1078
+ }));
1079
+ }
1080
+ Promise.all(jobs).then(() => process.exit(0)).catch(error => { console.error(error); process.exit(2); });
1081
+ `;
1082
+ const result = cp.spawnSync(process.execPath, ['-e', inline], {
1083
+ cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 60000,
1084
+ env: { ...process.env, PROBE_CLI: CLI, PROBE_ROOT: dir },
1085
+ });
1086
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1087
+ const history = readExecutionEvents(dir, 100, { preserveAll: true });
1088
+ assert.strictEqual(history.ok, true, history.error);
1089
+ const concurrent = history.events.filter(event => event.taskId === 'T-CONCURRENT' && event.event === 'execution.completed');
1090
+ assert.strictEqual(concurrent.length, 8);
1091
+ assert.strictEqual(new Set(concurrent.map(event => event.attemptId)).size, 8);
1092
+ } finally {
1093
+ fs.rmSync(dir, { recursive: true, force: true });
1094
+ }
1095
+ });
1096
+
1097
+ test('execution ledger masks credential-bearing keys without hiding audit metrics', () => {
1098
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ledger-key-redaction-'));
1099
+ try {
1100
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1101
+ const fullwidthApiKey = '\uff41\uff50\uff49\uff2b\uff45\uff59';
1102
+ const fullwidthPassword = '\uff50\uff41\uff53\uff53\uff57\uff4f\uff52\uff44';
1103
+ appendExecutionEvent(dir, {
1104
+ event: 'execution.completed',
1105
+ taskId: 'T-KEY-REDACTION',
1106
+ attemptId: 'impl-key-redaction',
1107
+ actualExecutor: { provider: 'synthetic' },
1108
+ evidenceRefs: ['key-redaction-evidence'],
1109
+ executed: true,
1110
+ result: {
1111
+ summary: 'key redaction fixture',
1112
+ apiKey: 'opaque-api-value',
1113
+ cookie: { sid: 'opaque-cookie-value' },
1114
+ authorization: 'opaque-auth-value',
1115
+ credentials: { username: 'u', password: 'opaque-password-value' },
1116
+ refreshToken: 'opaque-refresh-value',
1117
+ accessTokenValue: 'opaque-access-token-value',
1118
+ clientSecretValue: 'opaque-client-secret-value',
1119
+ apiKeyInput: 'opaque-api-input-value',
1120
+ passwordOutput: 'opaque-password-output-value',
1121
+ passphrase: 'opaque-passphrase-value',
1122
+ passPhrase: 'opaque-pass-phrase-value',
1123
+ APIKey: 'opaque-uppercase-api-key-value',
1124
+ clientAPIKey: 'opaque-client-uppercase-api-key-value',
1125
+ JWTToken: 'opaque-jwt-token-value',
1126
+ authorizationPrompt: 'opaque-authorization-prompt-value',
1127
+ [fullwidthApiKey]: 'opaque-fullwidth-api-key-value',
1128
+ [fullwidthPassword]: 'opaque-fullwidth-password-value',
1129
+ tokenCount: 17,
1130
+ tokenUsage: 23,
1131
+ tokenBudget: 100,
1132
+ secretScanStatus: 'clean',
1133
+ secretPolicy: 'no plaintext values',
1134
+ },
1135
+ });
1136
+ const history = readExecutionEvents(dir, 10);
1137
+ assert.strictEqual(history.ok, true);
1138
+ const event = history.events[history.events.length - 1];
1139
+ assert.strictEqual(event.result.apiKey, '***');
1140
+ assert.strictEqual(event.result.cookie, '***');
1141
+ assert.strictEqual(event.result.authorization, '***');
1142
+ assert.strictEqual(event.result.credentials, '***');
1143
+ assert.strictEqual(event.result.refreshToken, '***');
1144
+ assert.strictEqual(event.result.accessTokenValue, '***');
1145
+ assert.strictEqual(event.result.clientSecretValue, '***');
1146
+ assert.strictEqual(event.result.apiKeyInput, '***');
1147
+ assert.strictEqual(event.result.passwordOutput, '***');
1148
+ assert.strictEqual(event.result.passphrase, '***');
1149
+ assert.strictEqual(event.result.passPhrase, '***');
1150
+ assert.strictEqual(event.result.APIKey, '***');
1151
+ assert.strictEqual(event.result.clientAPIKey, '***');
1152
+ assert.strictEqual(event.result.JWTToken, '***');
1153
+ assert.strictEqual(event.result.authorizationPrompt, '***');
1154
+ assert.strictEqual(event.result[fullwidthApiKey], '***');
1155
+ assert.strictEqual(event.result[fullwidthPassword], '***');
1156
+ assert.strictEqual(event.result.tokenCount, 17);
1157
+ assert.strictEqual(event.result.tokenUsage, 23);
1158
+ assert.strictEqual(event.result.tokenBudget, 100);
1159
+ assert.strictEqual(event.result.secretScanStatus, 'clean');
1160
+ assert.strictEqual(event.result.secretPolicy, '***');
1161
+ const raw = fs.readFileSync(path.join(dir, '.leerness', 'execution-ledger.jsonl'), 'utf8');
1162
+ assert.doesNotMatch(raw, /opaque-(?:api|uppercase-api-key|client-uppercase-api-key|fullwidth-api-key|fullwidth-password|jwt-token|cookie|auth|password|passphrase|pass-phrase|refresh|access-token|client-secret)(?:-(?:input|output|prompt))?-value/);
1163
+ } finally {
1164
+ fs.rmSync(dir, { recursive: true, force: true });
1165
+ }
1166
+ });
1167
+
1168
+ test('append-only ledger redacts secret-looking values and tolerates one corrupt line on read', () => {
1169
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-role-ledger-'));
1170
+ try {
1171
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1172
+ appendExecutionEvent(dir, {
1173
+ schemaVersion: 999, event: 'execution.completed', taskId: 'T-1', attemptId: 'impl-1',
1174
+ actualExecutor: { provider: 'worker-a' }, result: { summary: 'token=sk-live-LEAKME' },
1175
+ evidenceRefs: ['implementation-evidence'], executed: true,
1176
+ });
1177
+ appendExecutionEvent(dir, {
1178
+ event: 'review.completed', taskId: 'T-1', attemptId: 'review-1', parentAttemptId: 'impl-1',
1179
+ reviewOfAttemptId: 'impl-1', actualExecutor: { provider: 'reviewer-b' },
1180
+ evidenceRefs: ['review-evidence'], executed: true,
1181
+ });
1182
+ const file = path.join(dir, '.leerness', 'execution-ledger.jsonl');
1183
+ fs.appendFileSync(file, '{broken\n', 'utf8');
1184
+ const history = readExecutionEvents(dir, 20);
1185
+ assert.strictEqual(history.events.length, 2);
1186
+ assert.strictEqual(history.ok, false);
1187
+ assert.strictEqual(history.code, 'ledger_invalid_lines');
1188
+ assert.strictEqual(history.invalidLines, 1);
1189
+ assert.ok(history.events.every(e => e.schemaVersion === 1));
1190
+ assert.doesNotMatch(JSON.stringify(history.events), /sk-live-LEAKME/);
1191
+ assert.match(JSON.stringify(history.events), /token=\*\*\*/);
1192
+ } finally {
1193
+ fs.rmSync(dir, { recursive: true, force: true });
1194
+ }
1195
+ });
1196
+
1197
+ test('actual multi and bench paths write provider-level provenance once per settled call without inventing model IDs', () => {
1198
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-agent-exec-provenance-'));
1199
+ try {
1200
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1201
+ const inline = String.raw`
1202
+ const fs = require('fs');
1203
+ const path = require('path');
1204
+ const { PassThrough } = require('stream');
1205
+ const { EventEmitter } = require('events');
1206
+ const { agentsCmd } = require(${JSON.stringify(path.join(__dirname, '..', 'lib', 'agents.js'))});
1207
+ const { readExecutionEvents } = require(${JSON.stringify(path.join(__dirname, '..', 'lib', 'role-fallback.js'))});
1208
+ const root = process.env.PROBE_ROOT;
1209
+ const base = {
1210
+ VERSION: 'test',
1211
+ arg: (flag, fallback = null) => flag === '--timeout' ? '1' : flag === '--only' ? 'codex' : fallback,
1212
+ _agentSlashHint: () => null,
1213
+ _allProviders: () => [{ id: 'codex', bin: 'codex', envFlag: 'LEERNESS_ENABLE_CODEX', versionArgs: ['--version'] }],
1214
+ _checkAgent: (def) => ({ id: def.id, status: def.id === 'codex' ? 'ready' : 'disabled', installed: true, enabled: def.id === 'codex', version: 'synthetic', auth: 'unknown' }),
1215
+ _cliChat: async () => ({ ok: true, response: 'synthetic response' }),
1216
+ _dispatchCommand: () => 'synthetic',
1217
+ _harnessBrief: () => '',
1218
+ _loadEnvFile: () => {},
1219
+ _normalizeRole: x => x,
1220
+ _policyEnforce: () => ({ allowed: true, advisory: false }),
1221
+ _readUserProviders: () => [],
1222
+ _recommendAgent: () => ({ target: null, reason: '' }),
1223
+ _recordRun: () => null,
1224
+ _resolveRole: () => null,
1225
+ _withLock: (_file, fn) => fn(),
1226
+ lessonsPath: () => path.join(root, '.leerness', 'lessons.md'),
1227
+ taskLogPath: () => path.join(root, '.leerness', 'task-log.md'),
1228
+ };
1229
+ function fakeChild() {
1230
+ const child = new EventEmitter();
1231
+ child.stdout = new PassThrough();
1232
+ child.stderr = new PassThrough();
1233
+ child.kill = () => {};
1234
+ process.nextTick(() => {
1235
+ child.stdout.write('synthetic bench response');
1236
+ child.stdout.end();
1237
+ child.stderr.end();
1238
+ child.emit('close', 0);
1239
+ });
1240
+ return child;
1241
+ }
1242
+ function fakeErrorThenCloseChild() {
1243
+ const child = new EventEmitter();
1244
+ child.stdout = new PassThrough();
1245
+ child.stderr = new PassThrough();
1246
+ child.kill = () => {};
1247
+ process.nextTick(() => {
1248
+ child.emit('error', new Error('synthetic spawn failure'));
1249
+ child.stdout.end();
1250
+ child.stderr.end();
1251
+ child.emit('close', -1);
1252
+ });
1253
+ return child;
1254
+ }
1255
+ (async () => {
1256
+ await agentsCmd(root, 'multi', ['synthetic multi'], { ...base, has: flag => flag === '--execute' || flag === '--json' });
1257
+ await agentsCmd(root, 'multi', ['synthetic multi preflight'], {
1258
+ ...base,
1259
+ has: flag => flag === '--execute' || flag === '--json',
1260
+ _cliChat: async () => ({ ok: false, response: '', error: 'synthetic preflight rejection', executed: false }),
1261
+ });
1262
+ process.env.LEERNESS_ENABLE_CODEX = '1';
1263
+ await agentsCmd(root, 'bench', ['synthetic bench'], { ...base, has: flag => flag === '--json', _spawnPortable: fakeChild });
1264
+ await agentsCmd(root, 'bench', ['synthetic bench error'], { ...base, has: flag => flag === '--json', _spawnPortable: fakeErrorThenCloseChild });
1265
+ const history = readExecutionEvents(root, 100);
1266
+ const required = ['execution.batch.started', 'execution.completed', 'benchmark.batch.started', 'benchmark.completed'];
1267
+ if (!required.every(name => history.events.some(event => event.event === name))) process.exit(3);
1268
+ const executed = history.events.filter(event => event.event === 'execution.completed' || event.event === 'benchmark.completed');
1269
+ if (!executed.every(event => event.executed === true && event.actualExecutor && event.actualExecutor.provider === 'codex' && event.actualExecutor.model === null && event.actualExecutor.identitySource === 'cli-default-unverified')) process.exit(4);
1270
+ const preflight = history.events.filter(event => event.task === 'synthetic multi preflight' && event.event === 'execution.failed');
1271
+ if (preflight.length !== 1 || preflight[0].executed !== false) process.exit(7);
1272
+ const terminal = history.events.filter(event => event.task === 'synthetic bench error' && event.event === 'benchmark.failed');
1273
+ const batches = history.events.filter(event => event.task === 'synthetic bench error' && event.event === 'benchmark.batch.started');
1274
+ if (terminal.length !== 1 || batches.length !== 1 || terminal[0].executed !== false) process.exit(6);
1275
+ for (const task of ['synthetic multi', 'synthetic multi preflight', 'synthetic bench', 'synthetic bench error']) {
1276
+ const events = history.events.filter(event => event.task === task);
1277
+ if (!events.length || events.some(event => typeof event.taskId !== 'string' || !event.taskId)) process.exit(8);
1278
+ if (new Set(events.map(event => event.taskId)).size !== 1) process.exit(9);
1279
+ }
1280
+ })().catch(error => { console.error(error); process.exit(5); });
1281
+ `;
1282
+ const result = cp.spawnSync(process.execPath, ['-e', inline], {
1283
+ cwd: path.join(__dirname, '..'),
1284
+ env: { ...process.env, PROBE_ROOT: dir, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1' },
1285
+ encoding: 'utf8',
1286
+ timeout: 30000,
1287
+ });
1288
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1289
+ } finally {
1290
+ fs.rmSync(dir, { recursive: true, force: true });
1291
+ }
1292
+ });
1293
+
1294
+ test('dispatch refuses to commit a role resolution after availability policy changes', () => {
1295
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-role-resolution-cas-'));
1296
+ const argv = ['role CAS task', '--role', 'coder', '--to', 'live', '--model', 'backup-model'];
1297
+ const valueFor = (flag, fallback = null) => {
1298
+ const index = argv.indexOf(flag);
1299
+ return index >= 0 && index + 1 < argv.length ? argv[index + 1] : fallback;
1300
+ };
1301
+ const previousExitCode = process.exitCode;
1302
+ const previousLog = console.log;
1303
+ try {
1304
+ fs.mkdirSync(path.join(dir, '.leerness'), { recursive: true });
1305
+ fs.writeFileSync(path.join(dir, '.leerness', 'agent-roles.json'), JSON.stringify({
1306
+ schemaVersion: 1,
1307
+ roles: {
1308
+ coder: {
1309
+ provider: 'dead', model: 'primary-model',
1310
+ primary: { provider: 'dead', model: 'primary-model' },
1311
+ candidates: [{ provider: 'live', model: 'backup-model' }],
1312
+ fallbackPolicy: 'balanced',
1313
+ },
1314
+ },
1315
+ }, null, 2) + '\n', 'utf8');
1316
+ const providers = [provider('dead', 'D'), provider('live', 'L')];
1317
+ const immediateLock = (_file, fn) => fn();
1318
+ let policyChanged = false;
1319
+ console.log = () => {};
1320
+ process.exitCode = 0;
1321
+ agentsCmd(dir, 'dispatch', argv, {
1322
+ VERSION: 'test',
1323
+ has: flag => argv.includes(flag),
1324
+ arg: valueFor,
1325
+ _agentSlashHint: () => null,
1326
+ _allProviders: () => providers,
1327
+ _checkAgent: def => ({ id: def.id, status: def.id === 'live' ? 'ready' : 'disabled', installed: true, enabled: def.id === 'live', auth: 'ok' }),
1328
+ _dispatchCommand: () => {
1329
+ appendAvailabilityObservation(dir, {
1330
+ provider: 'live', model: 'backup-model', policyAllowed: 'denied', reason: 'policy-changed-during-dispatch',
1331
+ }, { withLock: immediateLock });
1332
+ policyChanged = true;
1333
+ return 'synthetic command';
1334
+ },
1335
+ _harnessBrief: () => '',
1336
+ _loadEnvFile: () => {},
1337
+ _normalizeRole: value => value,
1338
+ _policyEnforce: () => ({ allowed: true, advisory: false }),
1339
+ _readUserProviders: () => [],
1340
+ _recommendAgent: () => ({ target: null, reason: '' }),
1341
+ _recordRun: () => null,
1342
+ _resolveRole: (root, role) => {
1343
+ const state = roleStore.readRoleStore(root);
1344
+ const normalized = normalizeRoleDefinition(role, state.roles[role]);
1345
+ return {
1346
+ role,
1347
+ provider: normalized.primary.provider,
1348
+ model: normalized.primary.model,
1349
+ modelFamily: normalized.primary.modelFamily,
1350
+ persona: normalized.persona,
1351
+ candidates: normalized.candidates,
1352
+ fallbackPolicy: normalized.fallbackPolicy,
1353
+ requirements: normalized.requirements,
1354
+ primary: normalized.primary,
1355
+ storeRevision: state.revision,
1356
+ };
1357
+ },
1358
+ _withLock: immediateLock,
1359
+ lessonsPath: () => path.join(dir, '.leerness', 'lessons.md'),
1360
+ taskLogPath: () => path.join(dir, '.leerness', 'task-log.md'),
1361
+ });
1362
+ assert.strictEqual(policyChanged, true);
1363
+ assert.strictEqual(process.exitCode, 1);
1364
+ const history = readExecutionEvents(dir, 100, { preserveAll: true });
1365
+ assert.strictEqual(history.events.some(event => event.event === 'dispatch.prepared'), false);
1366
+ assert.strictEqual(history.events.some(event => event.event === 'availability.observed'), true);
1367
+ } finally {
1368
+ console.log = previousLog;
1369
+ process.exitCode = previousExitCode;
1370
+ fs.rmSync(dir, { recursive: true, force: true });
1371
+ }
1372
+ });
1373
+
1374
+ const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-role-fallback-cli-'));
1375
+ try {
1376
+ fs.mkdirSync(path.join(fixture, '.leerness'), { recursive: true });
1377
+ fs.writeFileSync(path.join(fixture, '.leerness', 'providers.json'), JSON.stringify({
1378
+ schemaVersion: 1,
1379
+ providers: [
1380
+ { id: 'dead', bin: 'node', envFlag: 'LEERNESS_ENABLE_DEAD', versionArgs: ['--version'], desc: 'dead' },
1381
+ { id: 'live', bin: 'node', envFlag: 'LEERNESS_ENABLE_LIVE', versionArgs: ['--version'], desc: 'live' },
1382
+ ],
1383
+ }, null, 2));
1384
+ fs.writeFileSync(path.join(fixture, '.leerness', 'agent-roles.json'), JSON.stringify({
1385
+ schemaVersion: 1,
1386
+ roles: {
1387
+ coder: {
1388
+ primary: { provider: 'dead', model: 'primary-model' },
1389
+ provider: 'dead', model: 'primary-model',
1390
+ candidates: [{ provider: 'live', model: 'backup-model' }],
1391
+ fallbackPolicy: 'balanced',
1392
+ },
1393
+ },
1394
+ }, null, 2));
1395
+ const env = { LEERNESS_ENABLE_DEAD: '0', LEERNESS_ENABLE_LIVE: '1' };
1396
+
1397
+ test('CLI agents resolve returns structured choices and writes routing provenance', () => {
1398
+ const result = run(['agents', 'resolve', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1399
+ const out = json(result);
1400
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1401
+ assert.ok(out && out.ok === true);
1402
+ assert.strictEqual(out.resolution.primary.provider, 'dead');
1403
+ assert.strictEqual(out.resolution.primary.availability.eligible, false);
1404
+ assert.match(out.resolution.decision.recommendedOptionId, /^compatible-provider:/);
1405
+ assert.strictEqual(out.auditWritten, true);
1406
+ });
1407
+
1408
+ test('CLI invalid fallback preset fails closed before writing a routing decision', () => {
1409
+ const before = readExecutionEvents(fixture, 200, { preserveAll: true }).events.length;
1410
+ const result = run(['agents', 'resolve', '작은 API 수정', '--role', 'coder', '--preset', 'strcit', '--path', fixture, '--json'], { env });
1411
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1412
+ assert.strictEqual(json(result).code, 'invalid_fallback_policy');
1413
+ const after = readExecutionEvents(fixture, 200, { preserveAll: true }).events.length;
1414
+ assert.strictEqual(after, before);
1415
+ });
1416
+
1417
+ test('CLI rejects duplicate role, preset and fallback executor selectors before resolution or provider launch', () => {
1418
+ const before = readExecutionEvents(fixture, 200, { preserveAll: true }).events.length;
1419
+ let result = run(['agents', 'multi', 'duplicate role', '--role', '', '--role', 'coder', '--execute', '--path', fixture, '--json'], { env });
1420
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1421
+ assert.strictEqual(json(result).code, 'duplicate_flag');
1422
+ result = run(['agents', 'resolve', 'duplicate preset', '--role', 'coder', '--preset', 'balanced', '--preset', 'strcit', '--path', fixture, '--json'], { env });
1423
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1424
+ assert.strictEqual(json(result).code, 'duplicate_flag');
1425
+ result = run(['agents', 'fallback', 'provider', 'duplicate provider', '--role', 'coder', '--to', 'live', '--to', 'dead', '--path', fixture, '--json'], { env });
1426
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1427
+ assert.strictEqual(json(result).code, 'duplicate_flag');
1428
+ result = run(['agents', 'fallback', 'model', 'duplicate model alias', '--role', 'coder', '--to', 'live', '--model', 'backup-model', '-m', 'other-model', '--path', fixture, '--json'], { env });
1429
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1430
+ assert.strictEqual(json(result).code, 'duplicate_flag');
1431
+ const after = readExecutionEvents(fixture, 200, { preserveAll: true }).events.length;
1432
+ assert.strictEqual(after, before);
1433
+ });
1434
+
1435
+ test('CLI cannot lower detected high risk without a visible approver and reason', () => {
1436
+ let result = run(['agents', 'resolve', '운영 결제 DB 삭제', '--role', 'coder', '--tier', 'normal', '--path', fixture, '--json'], { env });
1437
+ let out = json(result);
1438
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1439
+ assert.strictEqual(out.classification.tier, 'high-risk');
1440
+ assert.strictEqual(out.classification.riskDowngrade.accepted, false);
1441
+ assert.strictEqual(out.resolution.decision.recommendedOptionId, 'hold');
1442
+
1443
+ result = run(['agents', 'resolve', '운영 결제 DB 삭제', '--role', 'coder', '--tier', 'normal', '--approved-by', 'owner', '--reason', 'approved maintenance window', '--path', fixture, '--json'], { env });
1444
+ out = json(result);
1445
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1446
+ assert.strictEqual(out.classification.tier, 'normal');
1447
+ assert.strictEqual(out.classification.riskDowngrade.accepted, true);
1448
+ assert.match(out.resolution.decision.recommendedOptionId, /^compatible-provider:/);
1449
+ });
1450
+
1451
+ test('legacy agents route shares the same explicit high-risk downgrade gate', () => {
1452
+ let result = run(['agents', 'route', '운영 결제 DB 삭제', '--tier', 'normal', '--path', fixture, '--json'], { env });
1453
+ let out = json(result);
1454
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1455
+ assert.strictEqual(out.tier, 'high-risk');
1456
+ assert.strictEqual(out.riskDowngrade.accepted, false);
1457
+
1458
+ result = run(['agents', 'route', '운영 결제 DB 삭제', '--tier', 'normal', '--approved-by', 'owner', '--reason', 'approved maintenance window', '--path', fixture, '--json'], { env });
1459
+ out = json(result);
1460
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1461
+ assert.strictEqual(out.tier, 'normal');
1462
+ assert.strictEqual(out.riskDowngrade.accepted, true);
1463
+ });
1464
+
1465
+ test('CLI multi and bench reject role-bound execution before provider launch', () => {
1466
+ let result = run(['agents', 'multi', 'role fanout', '--role', 'coder', '--execute', '--path', fixture, '--json'], { env });
1467
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1468
+ assert.strictEqual(json(result).code, 'role_multi_execution_unsupported');
1469
+ result = run(['agents', 'bench', 'role benchmark', '--role', 'coder', '--path', fixture, '--json'], { env });
1470
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1471
+ assert.strictEqual(json(result).code, 'role_benchmark_execution_unsupported');
1472
+ });
1473
+
1474
+ test('CLI observed quota exhaustion blocks the exact model until a clear event', () => {
1475
+ let result = run(['agents', 'availability', 'mark', 'live', '--model', 'backup-model', '--reason', 'quota-exhausted', '--ttl-min', '60', '--path', fixture, '--json'], { env });
1476
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1477
+ assert.strictEqual(json(result).active.length, 1);
1478
+ result = run(['agents', 'resolve', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1479
+ let out = json(result);
1480
+ const live = out.resolution.options.find(o => o.executor && o.executor.provider === 'live' && o.executor.model === 'backup-model');
1481
+ assert.strictEqual(live.availability.quota, 'exhausted');
1482
+ assert.strictEqual(live.availability.eligible, false);
1483
+ assert.ok(live.availability.blockingReasons.includes('quota-exhausted'));
1484
+ assert.strictEqual(out.resolution.decision.recommendedOptionId, 'hold');
1485
+ result = run(['roles', 'verify', '--path', fixture, '--json'], { env });
1486
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1487
+ let verified = json(result).results.find(row => row.role === 'coder');
1488
+ assert.strictEqual(verified.fallbackReady, false);
1489
+ assert.strictEqual(verified.routable, false);
1490
+ result = run(['agents', 'availability', 'clear', 'live', '--model', 'backup-model', '--path', fixture, '--json'], { env });
1491
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1492
+ result = run(['agents', 'resolve', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1493
+ out = json(result);
1494
+ assert.match(out.resolution.decision.recommendedOptionId, /^compatible-provider:/);
1495
+ result = run(['roles', 'verify', '--path', fixture, '--json'], { env });
1496
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1497
+ verified = json(result).results.find(row => row.role === 'coder');
1498
+ assert.strictEqual(verified.fallbackReady, true);
1499
+ assert.strictEqual(verified.routable, true);
1500
+ });
1501
+
1502
+ test('CLI quota output distinguishes observations from verified remaining amounts', () => {
1503
+ let result = run(['agents', 'availability', 'mark', 'live', '--model', 'backup-model', '--reason', 'quota-exhausted', '--ttl-min', '60', '--path', fixture, '--json'], { env });
1504
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1505
+ result = run(['agents', 'quota', '--path', fixture, '--json'], { env });
1506
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1507
+ const out = json(result);
1508
+ const live = out.quota.find(row => row.id === 'live');
1509
+ assert.strictEqual(live.quota, 'observed-exhausted');
1510
+ assert.strictEqual(live.quotaState, 'observed-exhausted');
1511
+ assert.strictEqual(live.remaining, null);
1512
+ assert.ok(live.availabilityObservations.some(observation => observation.model === 'backup-model' && observation.quota === 'exhausted'));
1513
+ assert.deepStrictEqual(live.observed, live.availabilityObservations);
1514
+ assert.strictEqual(typeof out.note, 'string');
1515
+ assert.match(out.note, /모델 권한|append-only/);
1516
+ assert.ok(out.limitations.includes('recorded_availability_is_not_official_capacity'));
1517
+ assert.strictEqual(out.policy.recordedAvailabilityTreatedAsOfficialCapacity, false);
1518
+ result = run(['agents', 'availability', 'clear', 'live', '--model', 'backup-model', '--path', fixture, '--json'], { env });
1519
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1520
+ });
1521
+
1522
+ test('CLI quota summary applies newer provider-wide recovery to older exact-model state', () => {
1523
+ let result = run(['agents', 'availability', 'mark', 'live', '--model', 'backup-model', '--quota-state', 'exhausted', '--rate-limited', '--reason', 'quota-exhausted', '--ttl-min', '60', '--path', fixture, '--json'], { env });
1524
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1525
+ result = run(['agents', 'availability', 'mark', 'live', '--quota-state', 'available', '--not-rate-limited', '--reason', 'capacity-restored', '--ttl-min', '60', '--path', fixture, '--json'], { env });
1526
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1527
+ result = run(['agents', 'quota', '--path', fixture, '--json'], { env });
1528
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1529
+ const live = json(result).quota.find(row => row.id === 'live');
1530
+ assert.strictEqual(live.quotaState, 'observed-available');
1531
+ assert.strictEqual(live.quota, 'observed-available');
1532
+ result = run(['agents', 'availability', 'clear', 'live', '--path', fixture, '--json'], { env });
1533
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1534
+ });
1535
+
1536
+ test('CLI role dispatch fails honestly and includes fallback options when primary is unavailable', () => {
1537
+ const result = run(['agents', 'dispatch', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1538
+ const out = json(result);
1539
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1540
+ assert.ok(out && out.code === 'role_executor_unavailable');
1541
+ assert.ok(Array.isArray(out.resolution.options));
1542
+ assert.ok(out.resolution.options.some(o => o.executor && o.executor.provider === 'live' && o.selectable));
1543
+ });
1544
+
1545
+ test('CLI role dispatch cannot bypass high-risk approval or reviewer-family independence with --to', () => {
1546
+ let result = run(['roles', 'set', 'reviewer', '--provider', 'dead', '--model', 'claude-sonnet-4-7', '--model-family', 'claude', '--candidate', 'live:gpt-5.6', '--candidate-family', 'openai-gpt', '--path', fixture, '--json'], { env });
1547
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1548
+
1549
+ result = run(['agents', 'dispatch', '결제 검수', '--role', 'reviewer', '--to', 'live', '--model', 'gpt-5.6', '--tier', 'high-risk', '--agent', 'worker', '--agent-model', 'claude-sonnet-4-7', '--agent-model-family', 'claude', '--path', fixture, '--json'], { env });
1550
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1551
+ assert.strictEqual(json(result).code, 'human_approval_required');
1552
+
1553
+ result = run(['agents', 'dispatch', '결제 검수', '--role', 'reviewer', '--to', 'live', '--model', 'gpt-5.6', '--tier', 'high-risk', '--agent', 'worker', '--agent-model', 'gpt-5.7', '--agent-model-family', 'openai-gpt', '--approved-by', 'owner', '--path', fixture, '--json'], { env });
1554
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1555
+ assert.strictEqual(json(result).code, 'fallback_choice_blocked');
1556
+
1557
+ result = run(['agents', 'dispatch', '결제 검수', '--role', 'reviewer', '--to', 'live', '--model', 'gpt-5.6', '--tier', 'high-risk', '--agent', 'worker', '--agent-model', 'claude-sonnet-4-7', '--agent-model-family', 'claude', '--approved-by', 'owner', '--path', fixture, '--json'], { env });
1558
+ const out = json(result);
1559
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1560
+ assert.strictEqual(out.action, 'dispatch-prepared');
1561
+ assert.strictEqual(out.executed, false);
1562
+ assert.match(out.taskId, /^task-/);
1563
+ assert.match(out.auditEventId, /^evt-/);
1564
+ const prepared = readExecutionEvents(fixture, 200, { preserveAll: true }).events
1565
+ .find(event => event.eventId === out.auditEventId);
1566
+ assert.ok(prepared);
1567
+ assert.strictEqual(prepared.taskId, out.taskId);
1568
+ assert.strictEqual(prepared.requestedRole, 'reviewer');
1569
+ });
1570
+
1571
+ test('CLI explicit --to cannot bypass an unconfigured role', () => {
1572
+ const result = run(['agents', 'dispatch', '작은 API 수정', '--role', 'architect', '--to', 'live', '--path', fixture, '--json'], { env });
1573
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1574
+ assert.strictEqual(json(result).code, 'role_unconfigured');
1575
+ });
1576
+
1577
+ test('CLI successful direct dispatch emits one valid JSON document and records prepared-not-executed provenance', () => {
1578
+ const result = run(['agents', 'dispatch', '작은 API 수정', '--to', 'live', '--model', 'backup-model', '--path', fixture, '--json'], { env });
1579
+ const out = json(result);
1580
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1581
+ assert.ok(out && out.action === 'dispatch-prepared');
1582
+ assert.strictEqual(out.executed, false);
1583
+ assert.strictEqual(out.auditWritten, true);
1584
+ assert.ok(out.command);
1585
+ });
1586
+
1587
+ test('CLI explicit provider fallback prepares but does not execute a command and records selection', () => {
1588
+ const result = run(['agents', 'fallback', 'provider', '작은 API 수정', '--role', 'coder', '--to', 'live', '--model', 'backup-model', '--path', fixture, '--json'], { env });
1589
+ const out = json(result);
1590
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1591
+ assert.ok(out && out.ok === true);
1592
+ assert.strictEqual(out.executed, false);
1593
+ assert.strictEqual(out.selected.executor.provider, 'live');
1594
+ assert.ok(out.command);
1595
+ assert.ok(out.auditEventId);
1596
+ });
1597
+
1598
+ test('CLI session fallback rejects incomplete identity and accepts explicit host identity', () => {
1599
+ let result = run(['agents', 'fallback', 'session', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1600
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1601
+ assert.strictEqual(json(result).code, 'fallback_choice_blocked');
1602
+ result = run(['agents', 'fallback', '--role', 'coder', 'session', '작은 API 수정', '--session-provider', 'openai', '--session-model', 'gpt-5.6-pro', '--session-model-family', 'openai-gpt', '--path', fixture, '--json'], { env });
1603
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1604
+ assert.strictEqual(json(result).selected.executor.provider, 'openai');
1605
+ assert.strictEqual(json(result).selected.executor.model, 'gpt-5.6-pro');
1606
+ });
1607
+
1608
+ test('CLI hold and execution/review record surfaces are visible in history', () => {
1609
+ let result = run(['agents', 'fallback', 'hold', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1610
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1611
+ seedRoleExecutionParent(fixture, 'T-9999', 'selection-9999');
1612
+ result = run(['agents', 'record', 'completed', '구현과 테스트 통과', '--task', 'T-9999', '--id', 'impl-9999', '--target', 'selection-9999', '--role', 'coder', '--to', 'live', '--model', 'claude-sonnet-4-7', '--evidence', 'test exit 0', '--path', fixture, '--json'], {
1613
+ env: {
1614
+ ...env,
1615
+ LEERNESS_SESSION_ID: 'host-session',
1616
+ LEERNESS_SESSION_PROVIDER: 'host-provider',
1617
+ LEERNESS_SESSION_MODEL: 'host-model',
1618
+ LEERNESS_SESSION_MODEL_FAMILY: 'host-family',
1619
+ LEERNESS_SESSION_IDENTITY_SOURCE: 'host-reported',
1620
+ },
1621
+ });
1622
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1623
+ assert.strictEqual(json(result).event.actualExecutor.identitySource, 'user-declared');
1624
+ assert.strictEqual(json(result).event.actualExecutor.provider, 'live');
1625
+ assert.strictEqual(json(result).event.actualExecutor.model, 'claude-sonnet-4-7');
1626
+ assert.strictEqual(json(result).event.actualExecutor.modelFamily, 'claude');
1627
+ assert.strictEqual(json(result).event.actualExecutor.sessionId, null);
1628
+ assert.strictEqual(json(result).event.requestedRole, 'coder');
1629
+ assert.deepStrictEqual(json(result).event.evidenceRefs, ['test exit 0']);
1630
+
1631
+ seedRoleExecutionParent(fixture, 'T-SAME-SESSION', 'selection-same-session');
1632
+ const sharedSessionEnv = {
1633
+ ...env,
1634
+ LEERNESS_SESSION_ID: 'mcp-shared-session',
1635
+ LEERNESS_SESSION_IDENTITY_SOURCE: 'mcp',
1636
+ };
1637
+ let sameSession = run(['agents', 'record', 'completed', 'same host implementation', '--task', 'T-SAME-SESSION', '--id', 'impl-same-session', '--target', 'selection-same-session', '--role', 'coder', '--to', 'live', '--model', 'claude-sonnet-4-7', '--evidence', 'implementation evidence', '--path', fixture, '--json'], {
1638
+ env: { ...sharedSessionEnv, LEERNESS_SESSION_PROVIDER: 'live', LEERNESS_SESSION_MODEL: 'claude-sonnet-4-7' },
1639
+ });
1640
+ assert.strictEqual(sameSession.status, 0, sameSession.stderr || sameSession.stdout);
1641
+ assert.strictEqual(json(sameSession).event.actualExecutor.sessionId, 'mcp-shared-session');
1642
+ sameSession = run(['agents', 'record', 'reviewed', 'same host review', '--task', 'T-SAME-SESSION', '--id', 'review-same-session', '--target', 'impl-same-session', '--role', 'reviewer', '--to', 'reviewer-b', '--model', 'gpt-5.6', '--evidence', 'review evidence', '--path', fixture, '--json'], {
1643
+ env: { ...sharedSessionEnv, LEERNESS_SESSION_PROVIDER: 'reviewer-b', LEERNESS_SESSION_MODEL: 'gpt-5.6' },
1644
+ });
1645
+ assert.strictEqual(sameSession.status, 0, sameSession.stderr || sameSession.stdout);
1646
+ assert.strictEqual(json(sameSession).event.actualExecutor.sessionId, 'mcp-shared-session');
1647
+ assert.strictEqual(json(sameSession).event.reviewerIndependent, false);
1648
+ assert.strictEqual(json(sameSession).event.review.independenceBasis, 'same-session');
1649
+
1650
+ result = run(['agents', 'record', '--task', 'T-9999', 'reviewed', '--summary', '독립 검수 통과', '--id', 'review-9999', '--target', 'impl-9999', '--role', 'reviewer', '--to', 'reviewer-b', '--model', 'gpt-5.6', '--agent', 'live', '--agent-model', 'claude-sonnet-4-7', '--evidence', 'no P0/P1', '--path', fixture, '--json'], { env });
1651
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1652
+ assert.strictEqual(json(result).event.reviewerIndependent, true);
1653
+ seedRoleExecutionParent(fixture, 'T-9999', 'selection-same');
1654
+ result = run(['agents', 'record', 'completed', '같은 세션 구현', '--task', 'T-9999', '--id', 'impl-same', '--target', 'selection-same', '--role', 'coder', '--to', 'live', '--model', 'claude-sonnet-4-7', '--session-id', 'shared-session', '--evidence', 'implementation evidence', '--path', fixture, '--json'], { env });
1655
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1656
+ result = run(['agents', 'record', 'reviewed', '같은 세션 검수', '--task', 'T-9999', '--id', 'review-same', '--target', 'impl-same', '--role', 'reviewer', '--to', 'reviewer-c', '--model-family', 'family-b', '--session-id', 'shared-session', '--evidence', 'same session', '--path', fixture, '--json'], { env });
1657
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1658
+ assert.strictEqual(json(result).event.reviewerIndependent, false);
1659
+ assert.strictEqual(json(result).event.review.independenceBasis, 'same-session');
1660
+ assert.strictEqual(json(result).event.reviewOfAttemptId, 'impl-same');
1661
+ seedRoleExecutionParent(fixture, 'T-9998', 'selection-9998');
1662
+ result = run(['agents', 'record', 'completed', '--task', 'T-9998', '--id', 'impl-9998', '--target', 'selection-9998', '--summary', '플래그 뒤 요약', '--role', 'coder', '--to', 'live', '--evidence', 'flag-order-evidence', '--path', fixture, '--json'], { env });
1663
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1664
+ assert.strictEqual(json(result).event.taskId, 'T-9998');
1665
+ assert.strictEqual(json(result).event.result.summary, '플래그 뒤 요약');
1666
+ result = run(['agents', 'record', 'failed', '중복 terminal', '--task', 'T-9998', '--id', 'impl-9998', '--target', 'selection-9998', '--role', 'coder', '--to', 'live', '--evidence', 'duplicate evidence', '--path', fixture, '--json'], { env });
1667
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1668
+ assert.strictEqual(json(result).code, 'attempt_already_terminal');
1669
+ const historyResult = run(['agents', 'history', '--limit', '50', '--path', fixture, '--json'], { env });
1670
+ const history = json(historyResult);
1671
+ assert.strictEqual(historyResult.status, 0, historyResult.stderr || historyResult.stdout);
1672
+ assert.ok(history.events.some(e => e.event === 'task.held'));
1673
+ assert.ok(history.events.some(e => e.event === 'execution.completed' && e.taskId === 'T-9999'));
1674
+ assert.strictEqual(history.events.filter(e => e.attemptId === 'impl-9998' && ['execution.completed', 'execution.failed'].includes(e.event)).length, 1);
1675
+ assert.ok(history.events.some(e => e.event === 'review.completed' && e.review && e.review.reviewerIndependent === true && e.review.reviewerIndependence === 'independent'));
1676
+ });
1677
+
1678
+ test('CLI review provenance is bound to one prior implementation attempt', () => {
1679
+ let result = run(['agents', 'record', 'reviewed', 'missing target', '--task', 'T-BIND', '--id', 'review-missing', '--target', 'impl-missing', '--to', 'reviewer-b', '--model', 'gpt-5.6', '--evidence', 'review evidence', '--path', fixture, '--json'], { env });
1680
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1681
+ assert.strictEqual(json(result).code, 'review_target_not_found');
1682
+
1683
+ seedRoleExecutionParent(fixture, 'T-BIND', 'selection-bound');
1684
+ result = run(['agents', 'record', 'completed', 'bound implementation', '--task', 'T-BIND', '--id', 'impl-bound', '--target', 'selection-bound', '--to', 'live', '--model', 'claude-sonnet-4-7', '--evidence', 'implementation evidence', '--path', fixture, '--json'], { env });
1685
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1686
+ result = run(['agents', 'record', 'reviewed', 'forged implementer', '--task', 'T-BIND', '--id', 'review-forged', '--target', 'impl-bound', '--to', 'reviewer-b', '--model', 'gpt-5.6', '--agent', 'someone-else', '--agent-model', 'claude-sonnet-4-7', '--evidence', 'review evidence', '--path', fixture, '--json'], { env });
1687
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1688
+ assert.strictEqual(json(result).code, 'review_target_identity_mismatch');
1689
+
1690
+ result = run(['agents', 'record', 'reviewed', 'bound review', '--task', 'T-BIND', '--id', 'review-bound', '--target', 'impl-bound', '--to', 'reviewer-b', '--model', 'gpt-5.6', '--evidence', 'review evidence', '--path', fixture, '--json'], { env });
1691
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1692
+ const event = json(result).event;
1693
+ assert.strictEqual(event.review.reviewOf.provider, 'live');
1694
+ assert.strictEqual(event.review.reviewOf.model, 'claude-sonnet-4-7');
1695
+ assert.strictEqual(event.reviewerIndependent, true);
1696
+ });
1697
+
1698
+ test('CLI terminal provenance rejects missing role-execution and validation parents', () => {
1699
+ let result = run(['agents', 'record', 'completed', 'missing role parent', '--task', 'T-PARENT', '--id', 'impl-parent', '--target', 'selection-missing', '--role', 'coder', '--to', 'live', '--evidence', 'evidence', '--path', fixture, '--json'], { env });
1700
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1701
+ assert.strictEqual(json(result).code, 'execution_target_not_found');
1702
+ result = run(['agents', 'record', 'completed', 'missing unscoped parent', '--task', 'T-PARENT', '--id', 'impl-parent-unscoped', '--target', 'selection-unscoped-missing', '--to', 'live', '--evidence', 'evidence', '--path', fixture, '--json'], { env });
1703
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1704
+ assert.strictEqual(json(result).code, 'execution_target_not_found');
1705
+
1706
+ seedRoleExecutionParent(fixture, 'T-PARENT', 'selection-identity');
1707
+ result = run(['agents', 'record', 'completed', 'mismatched executor', '--task', 'T-PARENT', '--id', 'impl-parent-identity', '--target', 'selection-identity', '--role', 'coder', '--to', 'dead', '--evidence', 'evidence', '--path', fixture, '--json'], { env });
1708
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1709
+ assert.strictEqual(json(result).code, 'execution_target_identity_mismatch');
1710
+
1711
+ seedRoleExecutionParent(fixture, 'T-PARENT', 'selection-role');
1712
+ result = run(['agents', 'record', 'completed', 'mismatched role', '--task', 'T-PARENT', '--id', 'impl-parent-role', '--target', 'selection-role', '--role', 'reviewer', '--to', 'live', '--evidence', 'evidence', '--path', fixture, '--json'], { env });
1713
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1714
+ assert.strictEqual(json(result).code, 'execution_target_role_mismatch');
1715
+
1716
+ appendExecutionEvent(fixture, {
1717
+ event: 'dispatch.prepared', taskId: 'T-PARENT', attemptId: 'selection-nonrole',
1718
+ actualExecutor: { provider: 'live', identitySource: 'synthetic-test' }, executed: false,
1719
+ });
1720
+ result = run(['agents', 'record', 'completed', 'role against non-role parent', '--task', 'T-PARENT', '--id', 'impl-parent-nonrole', '--target', 'selection-nonrole', '--role', 'coder', '--to', 'live', '--evidence', 'evidence', '--path', fixture, '--json'], { env });
1721
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1722
+ assert.strictEqual(json(result).code, 'execution_target_role_mismatch');
1723
+
1724
+ seedRoleExecutionParent(fixture, 'T-PARENT', 'selection-invisible-evidence');
1725
+ result = run(['agents', 'record', 'completed', 'invisible evidence', '--task', 'T-PARENT', '--id', 'impl-invisible-evidence', '--target', 'selection-invisible-evidence', '--role', 'coder', '--to', 'live', '--evidence', '\u{1bca0}', '--path', fixture, '--json'], { env });
1726
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1727
+ assert.strictEqual(json(result).code, 'provenance_fields_required');
1728
+ assert.match(json(result).error, /--evidence/);
1729
+
1730
+ result = run(['agents', 'record', 'validated', 'missing validation parent', '--task', 'T-PARENT', '--id', 'validation-parent', '--target', 'impl-missing', '--to', 'live', '--evidence', 'evidence', '--path', fixture, '--json'], { env });
1731
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1732
+ assert.strictEqual(json(result).code, 'validation_target_not_found');
1733
+ });
1734
+
1735
+ test('CLI rejects terminal identifiers that normalize or truncate before duplicate checks', () => {
1736
+ const cases = [
1737
+ ['--task', ' T-CANON '],
1738
+ ['--task', 'T-CA\u200bNON'],
1739
+ ['--id', 'impl\ncanon'],
1740
+ ['--id', 'x'.repeat(201)],
1741
+ ['--target', 'selection\tcanon'],
1742
+ ['--to', 'li\u200bve'],
1743
+ ['--to', '\uff4c\uff49\uff56\uff45'],
1744
+ ['--model', 'backup\u200b-model'],
1745
+ ['--model-family', 'openai\u200b-gpt'],
1746
+ ['--session-id', 'session\rcanon'],
1747
+ ];
1748
+ for (const [flag, value] of cases) {
1749
+ const args = ['agents', 'record', 'completed', 'noncanonical identifier', '--task', 'T-CANON', '--id', 'impl-canon', '--target', 'selection-canon', '--to', 'live', '--evidence', 'evidence', '--path', fixture, '--json'];
1750
+ const at = args.indexOf(flag);
1751
+ if (at >= 0) args[at + 1] = value;
1752
+ else args.splice(args.indexOf('--path'), 0, flag, value);
1753
+ const result = run(args, { env });
1754
+ assert.strictEqual(result.status, 1, `${flag}: ${result.stderr || result.stdout}`);
1755
+ assert.strictEqual(json(result).code, 'invalid_provenance_identifier', flag);
1756
+ }
1757
+ });
1758
+
1759
+ test('CLI terminal provenance requires task, attempt, executor, evidence and links', () => {
1760
+ const result = run(['agents', 'record', 'completed', '불완전 완료', '--task', 'T-INCOMPLETE', '--role', 'coder', '--to', 'live', '--path', fixture, '--json'], { env });
1761
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1762
+ assert.strictEqual(json(result).code, 'provenance_fields_required');
1763
+ assert.match(json(result).error, /--id/);
1764
+ assert.match(json(result).error, /--evidence/);
1765
+ assert.match(json(result).error, /--target/);
1766
+ });
1767
+
1768
+ test('roles set without --model preserves provider-default uncertainty instead of inventing a catalog model', () => {
1769
+ const result = run(['roles', 'set', 'architect', '--provider', 'live', '--path', fixture, '--json'], { env });
1770
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1771
+ const out = json(result);
1772
+ assert.strictEqual(out.model, null);
1773
+ assert.strictEqual(out.modelFamily, null);
1774
+ const stored = JSON.parse(fs.readFileSync(path.join(fixture, '.leerness', 'agent-roles.json'), 'utf8'));
1775
+ assert.strictEqual(stored.roles.architect.model, null);
1776
+ assert.strictEqual(stored.roles.architect.primary.model, null);
1777
+ });
1778
+
1779
+ test('roles set replaces the legacy policy alias without retaining contradictory stale policy', () => {
1780
+ const file = path.join(fixture, '.leerness', 'agent-roles.json');
1781
+ const stored = JSON.parse(fs.readFileSync(file, 'utf8'));
1782
+ stored.roles.architect.policy = 'strict';
1783
+ delete stored.roles.architect.fallbackPolicy;
1784
+ fs.writeFileSync(file, JSON.stringify(stored, null, 2) + '\n', 'utf8');
1785
+ const result = run(['roles', 'set', 'architect', '--provider', 'live', '--policy', 'continuity', '--path', fixture, '--json'], { env });
1786
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1787
+ const updated = JSON.parse(fs.readFileSync(file, 'utf8'));
1788
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(updated.roles.architect, 'policy'), false);
1789
+ assert.strictEqual(updated.roles.architect.fallbackPolicy, 'continuity');
1790
+ });
1791
+
1792
+ test('roles set keeps legacy schema v1 while retaining extended compatibility fields', () => {
1793
+ const result = run(['roles', 'set', 'debugger', '--provider', 'live', '--model', 'debug-model', '--path', fixture, '--json'], { env });
1794
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1795
+ const store = JSON.parse(fs.readFileSync(path.join(fixture, '.leerness', 'agent-roles.json'), 'utf8'));
1796
+ assert.strictEqual(store.schemaVersion, 1);
1797
+ assert.strictEqual(store.roles.debugger.provider, 'live');
1798
+ assert.deepStrictEqual(store.roles.debugger.primary, { provider: 'live', model: 'debug-model', modelFamily: null });
1799
+ assert.strictEqual(store.roles.debugger.fallbackPolicy, 'balanced');
1800
+ assert.ok(store.roles.debugger.requirements.reproduction);
1801
+ assert.strictEqual(json(result).warning, null, 'registered custom provider must use its real definition');
1802
+ });
1803
+
1804
+ test('roles set rejects unsafe or contradictory model identity without mutating the store', () => {
1805
+ const file = path.join(fixture, '.leerness', 'agent-roles.json');
1806
+ const before = fs.readFileSync(file);
1807
+ let result = run(['roles', 'set', 'debugger', '--provider', 'live', '--model', 'gpt-5.6;echo-injected', '--path', fixture, '--json'], { env });
1808
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1809
+ assert.strictEqual(json(result).code, 'role_definition_invalid');
1810
+ assert.deepStrictEqual(fs.readFileSync(file), before);
1811
+
1812
+ result = run(['roles', 'set', 'reviewer', '--provider', 'dead', '--model', 'claude-opus-4-7', '--model-family', 'openai-gpt', '--path', fixture, '--json'], { env });
1813
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1814
+ assert.strictEqual(json(result).code, 'role_definition_invalid');
1815
+ assert.deepStrictEqual(fs.readFileSync(file), before);
1816
+ });
1817
+
1818
+ test('roles set can configure ordered candidates, model families and fallback policy; verify reports fallback routability', () => {
1819
+ let result = run(['roles', 'set', 'reviewer', '--provider', 'dead', '--model', 'claude-sonnet-4-7', '--model-family', 'claude', '--policy', 'continuity', '--candidate', 'live:gpt-5.6', '--candidate-family', 'openai-gpt', '--path', fixture, '--json'], { env });
1820
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1821
+ const configured = json(result);
1822
+ assert.strictEqual(configured.fallbackPolicy, 'continuity');
1823
+ assert.strictEqual(configured.candidates.length, 1);
1824
+ assert.strictEqual(configured.candidates[0].provider, 'live');
1825
+ assert.strictEqual(configured.candidates[0].modelFamily, 'openai-gpt');
1826
+ let store = JSON.parse(fs.readFileSync(path.join(fixture, '.leerness', 'agent-roles.json'), 'utf8'));
1827
+ assert.strictEqual(store.roles.reviewer.primary.modelFamily, 'claude');
1828
+ assert.strictEqual(store.roles.reviewer.candidates[0].model, 'gpt-5.6');
1829
+ result = run(['roles', 'verify', '--path', fixture, '--json'], { env });
1830
+ const verified = json(result);
1831
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1832
+ const row = verified.results.find(r => r.role === 'reviewer');
1833
+ assert.strictEqual(row.primaryReady, false);
1834
+ assert.strictEqual(row.fallbackReady, true);
1835
+ assert.strictEqual(row.routable, true);
1836
+ result = run(['roles', 'set', 'reviewer', '--provider', 'dead', '--clear-candidates', '--path', fixture, '--json'], { env });
1837
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1838
+ store = JSON.parse(fs.readFileSync(path.join(fixture, '.leerness', 'agent-roles.json'), 'utf8'));
1839
+ assert.strictEqual(store.roles.reviewer.candidates.length, 0);
1840
+ });
1841
+
1842
+ test('roles set removes a legacy string candidate without dropping the remaining ordered candidates', () => {
1843
+ const file = path.join(fixture, '.leerness', 'agent-roles.json');
1844
+ const store = JSON.parse(fs.readFileSync(file, 'utf8'));
1845
+ store.roles.coder.candidates = ['live:backup-model', 'dead:secondary-model'];
1846
+ fs.writeFileSync(file, JSON.stringify(store, null, 2) + '\n', 'utf8');
1847
+ const result = run(['roles', 'set', 'coder', '--provider', 'dead', '--model', 'primary-model', '--remove-candidate', 'live:backup-model', '--path', fixture, '--json'], { env });
1848
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1849
+ const updated = JSON.parse(fs.readFileSync(file, 'utf8'));
1850
+ assert.deepStrictEqual(updated.roles.coder.candidates.map(item => `${item.provider}:${item.model || ''}`), ['dead:secondary-model']);
1851
+ });
1852
+
1853
+ test('execution provenance summary is bounded and lifecycle-ready', () => {
1854
+ const summary = _executionProvenanceSummary(fixture, 50);
1855
+ assert.ok(summary.events.some(e => e.event === 'execution.completed'));
1856
+ assert.ok(summary.events.some(e => e.event === 'review.completed'));
1857
+ assert.ok(summary.markdownLines.some(line => /review\.completed/.test(line)));
1858
+ assert.strictEqual(summary.known, true);
1859
+ });
1860
+
1861
+ test('MCP exposes and round-trips role fallback, availability, provenance and extended role configuration', () => {
1862
+ seedRoleExecutionParent(fixture, 'T-MCP-1', 'selection-mcp-1');
1863
+ seedRoleExecutionParent(fixture, 'T-MCP-SELF', 'selection-mcp-self');
1864
+ const requests = [
1865
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'role-fallback-probe', version: '1' } } },
1866
+ { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
1867
+ { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'leerness_agents_resolve', arguments: { path: fixture, task: '작은 API 수정', role: 'coder', sessionKey: 'mcp-role-session' } } },
1868
+ { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'leerness_agents_availability', arguments: { path: fixture, action: 'list' } } },
1869
+ { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'leerness_agents_quota', arguments: { path: fixture } } },
1870
+ { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'leerness_agents_fallback', arguments: { path: fixture, action: 'hold', task: '작은 API 수정', role: 'coder', taskId: 'T-MCP-1' } } },
1871
+ { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'leerness_agents_record', arguments: { path: fixture, event: 'completed', summary: 'MCP implementation complete', taskId: 'T-MCP-1', attemptId: 'impl-mcp-1', targetAttemptId: 'selection-mcp-1', role: 'coder', provider: 'live', model: 'backup-model', evidence: 'synthetic exit 0', sessionKey: 'mcp-role-session' } } },
1872
+ { jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'leerness_agents_history', arguments: { path: fixture, limit: 20 } } },
1873
+ { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'leerness_roles', arguments: { path: fixture, sub: 'set', role: 'dispatcher', provider: 'dead', model: 'claude-dispatch', modelFamily: 'claude', candidates: ['live:gpt-dispatch', 'codex:gpt-5'], candidateFamilies: ['openai-gpt', 'openai-gpt'], fallbackPolicy: 'continuity' } } },
1874
+ { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'leerness_agents_list', arguments: { path: fixture } } },
1875
+ { jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'leerness_agents_availability', arguments: { path: fixture, action: 'mark', provider: 'live', authenticated: 'ok', reason: 'credentials-confirmed', ttlMin: 5 } } },
1876
+ { jsonrpc: '2.0', id: 12, method: 'tools/call', params: { name: 'leerness_roles_validate', arguments: { path: fixture } } },
1877
+ { jsonrpc: '2.0', id: 13, method: 'tools/call', params: { name: 'leerness_roles_validate', arguments: { path: fixture, typoPath: path.dirname(fixture) } } },
1878
+ { jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: 'leerness_agents_availability', arguments: { path: fixture, action: 'mark', provider: 'live', rateLimited: true, reason: 'rate-limited', ttlMin: 5 } } },
1879
+ { jsonrpc: '2.0', id: 15, method: 'tools/call', params: { name: 'leerness_agents_availability', arguments: { path: fixture, action: 'mark', provider: 'live', rateLimited: false, quota: 'available', reason: 'capacity-restored', ttlMin: 5 } } },
1880
+ { jsonrpc: '2.0', id: 16, method: 'tools/call', params: { name: 'leerness_agents_quota', arguments: { path: fixture } } },
1881
+ { jsonrpc: '2.0', id: 17, method: 'tools/call', params: { name: 'leerness_agents_record', arguments: { path: fixture, event: 'completed', summary: 'same MCP connection implementation', taskId: 'T-MCP-SELF', attemptId: 'impl-mcp-self', targetAttemptId: 'selection-mcp-self', role: 'coder', provider: 'live', model: 'claude-sonnet-4-7', evidence: 'implementation evidence', sessionKey: 'mcp-impl-session' } } },
1882
+ { jsonrpc: '2.0', id: 18, method: 'tools/call', params: { name: 'leerness_agents_record', arguments: { path: fixture, event: 'reviewed', summary: 'same MCP connection review', taskId: 'T-MCP-SELF', attemptId: 'review-mcp-self', targetAttemptId: 'impl-mcp-self', role: 'reviewer', provider: 'dead', model: 'gpt-5.6', evidence: 'review evidence', sessionKey: 'mcp-review-session' } } },
1883
+ { jsonrpc: '2.0', id: 19, method: 'tools/call', params: { name: 'leerness_agents_record', arguments: { path: fixture, event: 'partial', summary: 'forged MCP executor session A', executorSession: 'attacker-session-a' } } },
1884
+ { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'leerness_agents_record', arguments: { path: fixture, event: 'partial', summary: 'forged MCP executor session B', executorSession: 'attacker-session-b' } } },
1885
+ ];
1886
+ const result = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--profile', 'full'], {
1887
+ cwd: path.join(__dirname, '..'),
1888
+ input: requests.map(request => JSON.stringify(request)).join('\n') + '\n',
1889
+ encoding: 'utf8',
1890
+ timeout: 180000,
1891
+ maxBuffer: 32 * 1024 * 1024,
1892
+ env: {
1893
+ ...process.env,
1894
+ LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_STALE_CHECK: '1',
1895
+ ...env,
1896
+ },
1897
+ });
1898
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
1899
+ const messages = String(result.stdout || '').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
1900
+ const byId = id => messages.find(message => message.id === id);
1901
+ const payload = id => {
1902
+ const message = byId(id);
1903
+ assert.ok(message && message.result && message.result.content && message.result.content[0], `missing MCP payload id=${id}: ${JSON.stringify(message)}`);
1904
+ return JSON.parse(message.result.content[0].text);
1905
+ };
1906
+ const tools = byId(2).result.tools.map(tool => tool.name);
1907
+ for (const name of ['leerness_agents_resolve', 'leerness_agents_fallback', 'leerness_agents_availability', 'leerness_agents_record', 'leerness_agents_history', 'leerness_agents_quota', 'leerness_roles_validate']) assert.ok(tools.includes(name), name);
1908
+ const rolesValidateTool = require('../lib/mcp-tools').find(tool => tool.name === 'leerness_roles_validate');
1909
+ const recordTool = require('../lib/mcp-tools').find(tool => tool.name === 'leerness_agents_record');
1910
+ assert.strictEqual(rolesValidateTool.requiredTier, 'read-only');
1911
+ assert.match(rolesValidateTool.description, /native v2 파일은 아직 읽지 않으며/);
1912
+ assert.strictEqual(recordTool.inputSchema.additionalProperties, false);
1913
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(recordTool.inputSchema.properties, 'executorSession'), false);
1914
+ assert.strictEqual(payload(3).resolution.role, 'coder');
1915
+ assert.strictEqual(payload(4).activeCount >= 0, true);
1916
+ const quota = payload(5);
1917
+ assert.ok(quota.quota.every(row => row.verifiedRemainingAmount === null));
1918
+ assert.strictEqual(payload(6).action, 'hold');
1919
+ assert.strictEqual(payload(7).event.taskId, 'T-MCP-1');
1920
+ const history = payload(8);
1921
+ assert.ok(history.events.some(event => event.event === 'task.held' && event.taskId === 'T-MCP-1'));
1922
+ assert.ok(history.events.some(event => event.event === 'execution.completed' && event.taskId === 'T-MCP-1'));
1923
+ assert.strictEqual(payload(9).fallbackPolicy, 'continuity');
1924
+ const listed = payload(10);
1925
+ assert.ok(listed.agents.some(agent => agent.id === 'dead'));
1926
+ assert.ok(listed.agents.some(agent => agent.id === 'live'));
1927
+ assert.strictEqual(payload(11).event.availability.authenticated, 'ok');
1928
+ assert.strictEqual(payload(12).ok, true);
1929
+ assert.strictEqual(byId(13)?.error?.code, -32602);
1930
+ assert.match(byId(13)?.error?.message || '', /unknown property 'typoPath'/);
1931
+ assert.strictEqual(payload(14).event.availability.rateLimited, true);
1932
+ assert.strictEqual(payload(15).event.availability.rateLimited, false);
1933
+ const restoredLive = payload(16).quota.find(row => row.id === 'live');
1934
+ assert.strictEqual(restoredLive.routingEligibility, 'eligible');
1935
+ assert.strictEqual(restoredLive.quotaState, 'observed-available');
1936
+ assert.match(payload(17).event.actualExecutor.sessionId, /^mcp-[0-9a-f]{32}$/);
1937
+ assert.strictEqual(payload(18).event.actualExecutor.sessionId, payload(17).event.actualExecutor.sessionId);
1938
+ assert.strictEqual(payload(18).event.reviewerIndependent, false);
1939
+ assert.strictEqual(payload(18).event.review.independenceBasis, 'same-session');
1940
+ for (const id of [19, 20]) {
1941
+ assert.strictEqual(byId(id)?.error?.code, -32602);
1942
+ assert.match(byId(id)?.error?.message || '', /unknown property 'executorSession'/);
1943
+ }
1944
+ const store = JSON.parse(fs.readFileSync(path.join(fixture, '.leerness', 'agent-roles.json'), 'utf8'));
1945
+ assert.strictEqual(store.roles.dispatcher.primary.modelFamily, 'claude');
1946
+ assert.strictEqual(store.roles.dispatcher.candidates.length, 2);
1947
+ assert.strictEqual(store.roles.dispatcher.candidates[0].modelFamily, 'openai-gpt');
1948
+ assert.strictEqual(store.roles.dispatcher.candidates[1].provider, 'codex');
1949
+ assert.strictEqual(store.roles.dispatcher.fallbackPolicy, 'continuity');
1950
+ });
1951
+
1952
+ test('malformed nested role store is rejected and never rewritten by roles set', () => {
1953
+ const file = path.join(fixture, '.leerness', 'agent-roles.json');
1954
+ const valid = fs.readFileSync(file, 'utf8');
1955
+ const invalidStores = [
1956
+ { schemaVersion: 1, roles: { coder: { provider: 'dead', primary: { provider: 'dead' }, fallbackPolicy: 'strcit' } } },
1957
+ { schemaVersion: 1, roles: { coder: { provider: 'dead', primary: { provider: 'dead' }, candidates: { provider: 'live' }, fallbackPolicy: 'strict' } } },
1958
+ { schemaVersion: 1, roles: { coder: { provider: 'dead', primary: { provider: 'live' }, fallbackPolicy: 'strict' } } },
1959
+ ];
1960
+ try {
1961
+ for (const store of invalidStores) {
1962
+ const bytes = JSON.stringify(store, null, 2) + '\n';
1963
+ fs.writeFileSync(file, bytes, 'utf8');
1964
+ let result = run(['roles', 'list', '--path', fixture, '--json'], { env });
1965
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1966
+ assert.strictEqual(json(result).code, 'store_invalid');
1967
+ result = run(['roles', 'set', 'coder', '--provider', 'live', '--path', fixture, '--json'], { env });
1968
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1969
+ assert.strictEqual(json(result).code, 'store_invalid');
1970
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes);
1971
+ }
1972
+ } finally {
1973
+ fs.writeFileSync(file, valid, 'utf8');
1974
+ }
1975
+ });
1976
+
1977
+ test('corrupt agent-roles store is reported and never overwritten by list, set, or role resolution', () => {
1978
+ const file = path.join(fixture, '.leerness', 'agent-roles.json');
1979
+ const corrupt = '{ broken role store\n';
1980
+ fs.writeFileSync(file, corrupt, 'utf8');
1981
+ let result = run(['roles', 'list', '--path', fixture, '--json'], { env });
1982
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1983
+ assert.strictEqual(json(result).code, 'store_corrupt');
1984
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), corrupt);
1985
+ result = run(['roles', 'set', 'coder', '--provider', 'live', '--path', fixture, '--json'], { env });
1986
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1987
+ assert.strictEqual(json(result).code, 'store_corrupt');
1988
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), corrupt);
1989
+ result = run(['agents', 'resolve', '작은 API 수정', '--role', 'coder', '--path', fixture, '--json'], { env });
1990
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
1991
+ assert.strictEqual(json(result).code, 'store_corrupt');
1992
+ assert.strictEqual(fs.readFileSync(file, 'utf8'), corrupt);
1993
+ });
1994
+
1995
+
1996
+ test('history reports a non-regular ledger as a structured error instead of crashing', () => {
1997
+ const ledger = path.join(fixture, '.leerness', 'execution-ledger.jsonl');
1998
+ fs.rmSync(ledger, { recursive: true, force: true });
1999
+ fs.mkdirSync(ledger);
2000
+ const result = run(['agents', 'history', '--path', fixture, '--json'], { env });
2001
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
2002
+ assert.strictEqual(json(result).code, 'ledger_not_regular_file');
2003
+ assert.ok(fs.statSync(ledger).isDirectory());
2004
+ });
2005
+ } finally {
2006
+ fs.rmSync(fixture, { recursive: true, force: true });
2007
+ }
2008
+
2009
+ console.log(`\nROLE_FALLBACK_PROBE ${passed}/${passed + failed}`);
2010
+ if (failed) process.exitCode = 1;