leerness 1.36.183 → 1.36.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agents.js CHANGED
@@ -5,9 +5,20 @@
5
5
  'use strict';
6
6
  const path = require('path');
7
7
  const fs = require('fs');
8
+ const crypto = require('crypto');
8
9
  const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
9
10
  const { EXTERNAL_AGENTS, AGENT_SLASH_COMMANDS } = require('./agent-registry');
10
- const { spawnPortable, spawnPortableSync } = require('./portable-process');
11
+ const { spawnPortable } = require('./portable-process');
12
+ const { classify, applyDeclaredRiskFloor } = require('./routing');
13
+ const roleStore = require('./role-store');
14
+ const {
15
+ resolveRoleFallback, selectFallbackOption, sessionIdentityFromEnv,
16
+ normalizeExecutorIdentity, canonicalProviderIdentity, canonicalLegacyProviderIdentity, assessReviewerIndependence, isValidModelIdentifier,
17
+ FALLBACK_POLICIES,
18
+ normalizeExecutionEvent, appendExecutionEvent, readExecutionEvents, executionLedgerPath,
19
+ appendAvailabilityObservation, appendAvailabilityClear,
20
+ readAvailabilityObservations, availabilityExtrasForCandidate, normalizeAvailability,
21
+ } = require('./role-fallback');
11
22
 
12
23
  function _benchLaunchSpec(agent, task, writeMode) {
13
24
  if (!agent || !agent.id) return { unsupported: 'provider definition missing' };
@@ -40,29 +51,443 @@ function _benchLaunchSpec(agent, task, writeMode) {
40
51
  }
41
52
  }
42
53
 
54
+ const _AGENT_VALUE_FLAGS = new Set([
55
+ '--to', '--provider', '--model', '-m', '--role', '--only', '--task', '--tier', '--preset',
56
+ '--approved-by', '--id', '--result', '--summary', '--status', '--agent', '--target', '--evidence',
57
+ '--limit', '--timeout', '--path', '--language',
58
+ '--session-id', '--session-provider', '--session-model', '--session-model-family',
59
+ '--model-family', '--agent-model', '--agent-model-family', '--agent-session',
60
+ '--auth-state', '--entitlement', '--quota-state', '--policy-state', '--reachability',
61
+ '--retry-after', '--expires-at', '--ttl-min', '--reason',
62
+ ]);
63
+
64
+ // Parse task/summary text without confusing option values for user text. The former
65
+ // value-based filter could drop a legitimate task whose text happened to equal a model
66
+ // name, and it did not cover newly added fallback/provenance flags. This parser follows
67
+ // argv positions instead: known value flags consume exactly their following argument,
68
+ // boolean/unknown flags are skipped, and `--` explicitly starts literal text.
69
+ function _parseAgentPositional(argv, start = 0) {
70
+ const list = Array.isArray(argv) ? argv : [];
71
+ const out = [];
72
+ for (let i = Math.max(0, start | 0); i < list.length; i++) {
73
+ const token = String(list[i] == null ? '' : list[i]);
74
+ if (token === '--') {
75
+ for (let j = i + 1; j < list.length; j++) out.push(String(list[j] == null ? '' : list[j]));
76
+ break;
77
+ }
78
+ if (_AGENT_VALUE_FLAGS.has(token)) { if (i + 1 < list.length) i++; continue; }
79
+ if (/^--[^=]+=/.test(token) || token.startsWith('-')) continue;
80
+ out.push(token);
81
+ }
82
+ return out.join(' ').trim();
83
+ }
84
+
85
+ function _findAgentAction(argv, allowedActions) {
86
+ const allowed = allowedActions instanceof Set ? allowedActions : new Set(allowedActions || []);
87
+ const list = Array.isArray(argv) ? argv : [];
88
+ for (let i = 0; i < list.length; i++) {
89
+ const token = String(list[i] == null ? '' : list[i]);
90
+ if (token === '--') break;
91
+ if (_AGENT_VALUE_FLAGS.has(token)) { if (i + 1 < list.length) i++; continue; }
92
+ if (/^--[^=]+=/.test(token) || token.startsWith('-')) continue;
93
+ const action = token.trim().toLowerCase();
94
+ if (allowed.has(action)) return { action, index: i };
95
+ }
96
+ return { action: null, index: -1 };
97
+ }
98
+
99
+ function _singletonFlagIssue(argv, flag) {
100
+ const values = [];
101
+ const list = Array.isArray(argv) ? argv : [];
102
+ for (let i = 0; i < list.length; i++) {
103
+ const token = String(list[i] == null ? '' : list[i]);
104
+ if (token === '--') break;
105
+ if (token === flag) {
106
+ const next = list[i + 1];
107
+ if (next == null || String(next).startsWith('--')) values.push(null);
108
+ else { values.push(String(next)); i += 1; }
109
+ } else if (token.startsWith(flag + '=')) {
110
+ values.push(token.slice(flag.length + 1));
111
+ }
112
+ }
113
+ if (values.length > 1) return { code: 'duplicate_flag', error: `${flag} may be specified only once` };
114
+ if (values.length === 1 && values[0] == null) return { code: 'missing_flag_value', error: `${flag} requires a value` };
115
+ if (values.length === 1 && !values[0].trim()) return { code: 'empty_flag_value', error: `${flag} requires a non-empty value` };
116
+ return null;
117
+ }
118
+
119
+ function _singletonFlagGroupIssue(argv, flags, label) {
120
+ const list = Array.isArray(argv) ? argv : [];
121
+ let count = 0;
122
+ for (let i = 0; i < list.length; i++) {
123
+ const token = String(list[i] == null ? '' : list[i]);
124
+ if (token === '--') break;
125
+ const matched = flags.find(flag => token === flag || token.startsWith(flag + '='));
126
+ if (!matched) continue;
127
+ count += 1;
128
+ if (token === matched && i + 1 < list.length && !String(list[i + 1]).startsWith('--')) i += 1;
129
+ }
130
+ return count > 1 ? { code: 'duplicate_flag', error: `${label || flags.join('/')} may be specified only once` } : null;
131
+ }
132
+
133
+ function _identityAssertionMatches(assertedValue, actualValue) {
134
+ const asserted = normalizeExecutorIdentity(assertedValue, { allowLegacyProviderIds: true });
135
+ const actual = normalizeExecutorIdentity(actualValue, { allowLegacyProviderIds: true });
136
+ const comparable = [
137
+ ['provider', value => String(value).toLowerCase()],
138
+ ['model', value => String(value)],
139
+ ['modelFamily', value => String(value).toLowerCase()],
140
+ ['sessionId', value => String(value)],
141
+ ];
142
+ for (const [key, normalize] of comparable) {
143
+ if (asserted[key] && (!actual[key] || normalize(asserted[key]) !== normalize(actual[key]))) return false;
144
+ }
145
+ return true;
146
+ }
147
+
148
+ function _generatedTaskId(prefix = 'task') {
149
+ return `${prefix}-${crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex')}`;
150
+ }
151
+
43
152
  function agentsCmd(root, sub, args = [], deps = {}) {
44
- const { VERSION, has, arg, _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, lessonsPath, taskLogPath } = deps;
153
+ const { VERSION, has, arg, _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, _withLock, lessonsPath, taskLogPath } = deps;
45
154
  const _spawnPortable = deps._spawnPortable || spawnPortable;
46
155
  root = absRoot(root || process.cwd());
47
156
  const en = deps.uiLang === 'en';
48
157
  const t = (ko, enText) => (en ? enText : ko);
158
+ // Security-sensitive selectors are singletons. `arg()` intentionally returns
159
+ // the first occurrence for broad backwards compatibility, but role/policy and
160
+ // provenance gates must never let a later duplicate be silently ignored.
161
+ const singletonFlags = new Set(_AGENT_VALUE_FLAGS);
162
+ const rawArgv = Array.isArray(deps.rawArgv) ? deps.rawArgv : process.argv.slice(2);
163
+ for (const flag of singletonFlags) {
164
+ const issue = _singletonFlagIssue(rawArgv, flag);
165
+ if (issue) { failJson(has('--json'), issue.code, issue.error); return; }
166
+ }
167
+ for (const [flags, label] of [
168
+ [['--to', '--provider'], '--to/--provider'],
169
+ [['--model', '-m'], '--model/-m'],
170
+ ]) {
171
+ const issue = _singletonFlagGroupIssue(rawArgv, flags, label);
172
+ if (issue) { failJson(has('--json'), issue.code, issue.error); return; }
173
+ }
49
174
  // 1.9.435 (11th 외부평가 Codex P2, UR-0137): dispatch/multi task 파싱 — flag 값이 task 본문에 흡수되던 버그 수정.
50
175
  // 상위(bin)에서 args 는 '--to' flag 만 제거되고 값(codex)은 positional 로 남아 기존 filter 가 task 에 흡수시켰음.
51
176
  // → flag 에서 break + lib 가 소비하는 값-flag(--to/--model/--role/--only) 값을 제외. 명시 task 는 --task 폴백.
52
- const _taskArg = () => {
53
- const consumed = new Set([arg('--to', null), arg('--model', null), arg('--role', null), arg('--only', null)].filter(Boolean));
54
- const p = [];
55
- for (const a of args) { if (a.startsWith('-')) break; if (consumed.has(a)) continue; p.push(a); }
56
- return p.join(' ').trim() || arg('--task', null);
57
- };
177
+ const _positionalText = (start = 0) => _parseAgentPositional(args, start);
178
+ const _taskArg = () => _positionalText(0) || arg('--task', null);
179
+ const _taskArgFrom = (start = 0) => _positionalText(start) || arg('--task', null);
58
180
  // .env 자동 로드 (1.9.22)
59
181
  _loadEnvFile(root);
60
182
  _loadEnvFile(path.join(root, '..'));
61
183
 
184
+
185
+ const _ledgerEvent = (event) => {
186
+ try { return appendExecutionEvent(root, event, { withLock: _withLock }); }
187
+ catch (error) { return { ok: false, error: error && error.message ? error.message : String(error), code: error && error.code ? error.code : 'ledger_write_failed' }; }
188
+ };
189
+ const _commitRoleResolutionEvent = (result, event) => {
190
+ if (!result || !result.snapshot) return _ledgerEvent(event);
191
+ if (typeof _withLock !== 'function') {
192
+ return { ok: false, code: 'resolution_commit_unavailable', error: 'role resolution commit requires the canonical lock' };
193
+ }
194
+ try {
195
+ return _withLock(roleStore.rolesFile(root), () => {
196
+ const currentRoleState = roleStore.readRoleStore(root);
197
+ if (!currentRoleState.ok) throw new roleStore.RoleStoreError(currentRoleState);
198
+ if (currentRoleState.revision !== result.snapshot.roleStoreRevision) {
199
+ return { ok: false, code: 'resolution_stale', error: 'role configuration changed after resolution; resolve again' };
200
+ }
201
+ return _withLock(executionLedgerPath(root), () => {
202
+ const currentAvailability = readAvailabilityObservations(root, 2000);
203
+ if (currentAvailability.ok === false || currentAvailability.partial) {
204
+ return {
205
+ ok: false,
206
+ code: currentAvailability.code || 'availability_history_partial',
207
+ error: currentAvailability.error || 'availability observation history is incomplete',
208
+ };
209
+ }
210
+ if (currentAvailability.revision !== result.snapshot.availabilityLedgerRevision) {
211
+ return { ok: false, code: 'resolution_stale', error: 'availability policy changed after resolution; resolve again' };
212
+ }
213
+ return appendExecutionEvent(root, {
214
+ ...event,
215
+ resolutionId: result.resolutionId,
216
+ roleStoreRevision: result.snapshot.roleStoreRevision,
217
+ availabilityLedgerRevision: result.snapshot.availabilityLedgerRevision,
218
+ riskDowngrade: result.cls && result.cls.riskDowngrade || null,
219
+ });
220
+ });
221
+ });
222
+ } catch (error) {
223
+ if (error instanceof roleStore.RoleStoreError) {
224
+ const publicFailure = roleStore.publicError(error.roleStoreState);
225
+ return { ok: false, code: publicFailure.code, error: publicFailure.error, roleStore: publicFailure };
226
+ }
227
+ return { ok: false, code: (error && error.code) || 'resolution_commit_failed', error: (error && error.message) || String(error) };
228
+ }
229
+ };
230
+ const _commitTerminalEvent = (event) => {
231
+ if (typeof _withLock !== 'function') {
232
+ return { ok: false, code: 'provenance_commit_unavailable', error: 'terminal provenance requires the canonical ledger lock' };
233
+ }
234
+ try {
235
+ return _withLock(executionLedgerPath(root), () => {
236
+ let canonicalEvent;
237
+ try { canonicalEvent = normalizeExecutionEvent(event); }
238
+ catch (error) {
239
+ return { ok: false, code: (error && error.code) || 'provenance_event_invalid', error: (error && error.message) || String(error) };
240
+ }
241
+ const history = readExecutionEvents(root, 2000, { preserveAll: true, maxBytes: 64 * 1024 * 1024 });
242
+ if (history.ok === false || history.truncated) {
243
+ return { ok: false, code: history.code || 'ledger_history_partial', error: history.error || 'execution ledger history is incomplete' };
244
+ }
245
+ const normalizedHistory = history.events.map(item => normalizeExecutionEvent(item));
246
+ const duplicate = normalizedHistory.some(item => item
247
+ && item.attemptId === canonicalEvent.attemptId
248
+ && ['execution.completed', 'execution.failed', 'review.completed', 'validation.completed'].includes(item.event));
249
+ if (duplicate) return { ok: false, code: 'attempt_already_terminal', error: `attempt already has a terminal event: ${canonicalEvent.attemptId}` };
250
+
251
+ const resolveParent = (targetId, allowedEvents, prefix, noun, matchEventId = false) => {
252
+ const candidates = normalizedHistory.filter(item => item && allowedEvents.includes(item.event)
253
+ && (item.attemptId === targetId || (matchEventId && item.eventId === targetId)));
254
+ const sameTask = candidates.filter(item => item.taskId === canonicalEvent.taskId);
255
+ if (!sameTask.length) {
256
+ return {
257
+ error: candidates.length
258
+ ? { ok: false, code: `${prefix}_target_task_mismatch`, error: `${noun} target ${targetId} belongs to a different task` }
259
+ : { ok: false, code: `${prefix}_target_not_found`, error: `${noun} target not found: ${targetId}` },
260
+ };
261
+ }
262
+ if (sameTask.length !== 1) {
263
+ return { error: { ok: false, code: `${prefix}_target_ambiguous`, error: `${noun} target is ambiguous: ${targetId}` } };
264
+ }
265
+ return { target: sameTask[0] };
266
+ };
267
+
268
+ if (canonicalEvent.event === 'review.completed') {
269
+ const targetId = canonicalEvent.reviewOfAttemptId;
270
+ const resolved = resolveParent(targetId, ['execution.completed', 'execution.failed'], 'review', 'review implementation');
271
+ if (resolved.error) return resolved.error;
272
+ const target = resolved.target;
273
+ if (!target.actualExecutor || !target.actualExecutor.provider) {
274
+ return { ok: false, code: 'review_target_executor_missing', error: `review target has no executor identity: ${targetId}` };
275
+ }
276
+ const assertedImplementer = canonicalEvent.review && canonicalEvent.review.reviewOf;
277
+ if (assertedImplementer && !_identityAssertionMatches(assertedImplementer, target.actualExecutor)) {
278
+ return { ok: false, code: 'review_target_identity_mismatch', error: `declared implementer does not match review target: ${targetId}` };
279
+ }
280
+ const independence = assessReviewerIndependence(canonicalEvent.actualExecutor, target.actualExecutor);
281
+ canonicalEvent = {
282
+ ...canonicalEvent,
283
+ parentAttemptId: target.attemptId,
284
+ reviewOfAttemptId: target.attemptId,
285
+ reviewerIndependent: independence.reviewerIndependent,
286
+ review: {
287
+ reviewOfAttemptId: target.attemptId,
288
+ reviewOf: independence.implementer,
289
+ reviewerIndependent: independence.reviewerIndependent,
290
+ reviewerIndependence: independence.status,
291
+ independenceBasis: independence.basis,
292
+ },
293
+ };
294
+ } else if (canonicalEvent.event === 'validation.completed') {
295
+ const targetId = canonicalEvent.parentAttemptId;
296
+ const resolved = resolveParent(
297
+ targetId,
298
+ ['execution.completed', 'execution.failed', 'review.completed'],
299
+ 'validation',
300
+ 'validation',
301
+ );
302
+ if (resolved.error) return resolved.error;
303
+ canonicalEvent = { ...canonicalEvent, parentAttemptId: resolved.target.attemptId };
304
+ } else if (['execution.completed', 'execution.failed'].includes(canonicalEvent.event)
305
+ && canonicalEvent.parentAttemptId) {
306
+ const targetId = canonicalEvent.parentAttemptId;
307
+ const resolved = resolveParent(
308
+ targetId,
309
+ ['routing.proposed', 'fallback.selected', 'dispatch.prepared', 'execution.started'],
310
+ 'execution',
311
+ 'role execution',
312
+ true,
313
+ );
314
+ if (resolved.error) return resolved.error;
315
+ const target = resolved.target;
316
+ const targetRole = target.requestedRole || null;
317
+ if (canonicalEvent.requestedRole && targetRole && canonicalEvent.requestedRole !== targetRole) {
318
+ return { ok: false, code: 'execution_target_role_mismatch', error: `terminal role does not match parent role: ${targetId}` };
319
+ }
320
+ if (canonicalEvent.requestedRole && !targetRole) {
321
+ return { ok: false, code: 'execution_target_role_mismatch', error: `role terminal event targets a non-role parent: ${targetId}` };
322
+ }
323
+ const expectedExecutor = target.actualExecutor && target.actualExecutor.provider
324
+ ? target.actualExecutor : target.requestedExecutor;
325
+ if (!expectedExecutor || !expectedExecutor.provider) {
326
+ return { ok: false, code: 'execution_target_executor_missing', error: `execution parent has no executor identity: ${targetId}` };
327
+ }
328
+ if (!_identityAssertionMatches(expectedExecutor, canonicalEvent.actualExecutor)) {
329
+ return { ok: false, code: 'execution_target_identity_mismatch', error: `terminal executor does not match execution parent: ${targetId}` };
330
+ }
331
+ canonicalEvent = {
332
+ ...canonicalEvent,
333
+ parentAttemptId: target.attemptId || target.eventId,
334
+ requestedRole: targetRole || canonicalEvent.requestedRole || null,
335
+ role: targetRole || canonicalEvent.requestedRole || null,
336
+ requestedExecutor: target.requestedExecutor || canonicalEvent.requestedExecutor || null,
337
+ };
338
+ } else if (['execution.completed', 'execution.failed'].includes(canonicalEvent.event)
339
+ && canonicalEvent.requestedRole) {
340
+ return { ok: false, code: 'execution_target_required', error: 'role terminal event requires a parent routing or dispatch target' };
341
+ }
342
+ return appendExecutionEvent(root, canonicalEvent);
343
+ });
344
+ } catch (error) {
345
+ return { ok: false, code: (error && error.code) || 'provenance_commit_failed', error: (error && error.message) || String(error) };
346
+ }
347
+ };
348
+ const _sessionIdentity = () => {
349
+ const base = sessionIdentityFromEnv(process.env);
350
+ const explicit = [
351
+ '--session-id', '--session-provider', '--session-model', '--session-model-family',
352
+ ].some(flag => has(flag));
353
+ return normalizeExecutorIdentity({
354
+ sessionId: arg('--session-id', base.sessionId),
355
+ provider: arg('--session-provider', base.provider),
356
+ model: arg('--session-model', base.model),
357
+ modelFamily: arg('--session-model-family', base.modelFamily),
358
+ identitySource: explicit ? 'user-declared' : base.identitySource,
359
+ }, { allowLegacyProviderIds: true });
360
+ };
361
+ const _failRoleResolution = (result) => {
362
+ if (result && result.roleStoreFailure) {
363
+ const payload = { ok: false, ...result.roleStoreFailure };
364
+ if (has('--json')) log(JSON.stringify(payload, null, 2));
365
+ else fail(payload.error || '역할 설정 파일이 유효하지 않습니다');
366
+ process.exitCode = 1;
367
+ return;
368
+ }
369
+ failJson(has('--json'), result && result.code || 'role_resolution_failed', result && result.error || 'role resolution failed');
370
+ };
371
+ const _roleResolution = (roleArg, task) => {
372
+ const role = _normalizeRole(roleArg);
373
+ let resolved;
374
+ try { resolved = _resolveRole(root, role); }
375
+ catch (error) {
376
+ if (error instanceof roleStore.RoleStoreError) {
377
+ const roleStoreFailure = roleStore.publicError(error.roleStoreState);
378
+ return { ok: false, code: roleStoreFailure.code, error: roleStoreFailure.error, roleStoreFailure, role };
379
+ }
380
+ return { ok: false, code: (error && error.code) || 'role_store_error', error: (error && error.message) || 'agent-roles.json을 읽을 수 없습니다', role };
381
+ }
382
+ if (!resolved) return { ok: false, code: 'role_unconfigured', error: `역할 미설정: ${role}`, role };
383
+ let roleStoreRevision = resolved.storeRevision || null;
384
+ if (!roleStoreRevision) {
385
+ const roleState = roleStore.readRoleStore(root);
386
+ if (!roleState.ok) {
387
+ const roleStoreFailure = roleStore.publicError(roleState);
388
+ return { ok: false, code: roleStoreFailure.code, error: roleStoreFailure.error, roleStoreFailure, role };
389
+ }
390
+ roleStoreRevision = roleState.revision;
391
+ }
392
+ const requestedPreset = arg('--preset', null);
393
+ if (requestedPreset != null && !FALLBACK_POLICIES.includes(String(requestedPreset).trim().toLowerCase())) {
394
+ return { ok: false, code: 'invalid_fallback_policy', error: `--preset must be one of ${FALLBACK_POLICIES.join('|')}`, role };
395
+ }
396
+ const classified = classify(task, { tier: arg('--tier', '') || '' });
397
+ const cls = applyDeclaredRiskFloor(classified, {
398
+ approvedBy: arg('--approved-by', null),
399
+ reason: arg('--reason', null),
400
+ });
401
+ if (!cls.ok) return { ok: false, code: cls.code, error: cls.error, role };
402
+ const providers = _allProviders(root);
403
+ const availabilityState = readAvailabilityObservations(root, 2000);
404
+ if (availabilityState.ok === false || availabilityState.partial) {
405
+ return {
406
+ ok: false,
407
+ code: availabilityState.code || 'availability_history_partial',
408
+ error: availabilityState.error || 'availability observation history is incomplete',
409
+ role,
410
+ };
411
+ }
412
+ const resolution = resolveRoleFallback({
413
+ role,
414
+ roleDefinition: resolved,
415
+ allowLegacyProviderIds: true,
416
+ providers,
417
+ tier: cls.tier,
418
+ policy: requestedPreset == null ? (resolved.fallbackPolicy || 'balanced') : String(requestedPreset).trim().toLowerCase(),
419
+ sessionIdentity: _sessionIdentity(),
420
+ implementerIdentity: normalizeExecutorIdentity({
421
+ provider: arg('--agent', null),
422
+ model: arg('--agent-model', null),
423
+ modelFamily: arg('--agent-model-family', null),
424
+ sessionId: arg('--agent-session', null),
425
+ identitySource: 'user-declared',
426
+ }, { allowLegacyProviderIds: true }),
427
+ checkProvider: (definition) => _checkAgent(definition, {
428
+ auth: !!(definition && definition.envFlag && process.env[definition.envFlag] === '1'),
429
+ }),
430
+ availabilityExtras: candidate => availabilityExtrasForCandidate(availabilityState, candidate),
431
+ });
432
+ resolution.availabilityObservationState = {
433
+ activeCount: availabilityState.activeCount,
434
+ partial: availabilityState.partial,
435
+ truncated: availabilityState.truncated,
436
+ };
437
+ const resolutionId = `res-${crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex')}`;
438
+ resolution.resolutionId = resolutionId;
439
+ resolution.roleStoreRevision = roleStoreRevision;
440
+ resolution.availabilityLedgerRevision = availabilityState.revision || null;
441
+ resolution.riskDowngrade = cls.riskDowngrade || null;
442
+ return {
443
+ ok: true,
444
+ role,
445
+ resolved,
446
+ cls,
447
+ resolution,
448
+ resolutionId,
449
+ snapshot: {
450
+ roleStoreRevision,
451
+ availabilityLedgerRevision: availabilityState.revision || null,
452
+ },
453
+ };
454
+ };
455
+ const _renderResolution = (result, task) => {
456
+ const r = result.resolution;
457
+ log(t(`# 역할 실행자 해석: ${r.role} · 위험도 ${r.tier} · 정책 ${r.policy}`, `# Role executor resolution: ${r.role} · risk ${r.tier} · policy ${r.policy}`));
458
+ if (r.primary) {
459
+ log(t(
460
+ `기본: ${r.primary.provider}${r.primary.model ? ' / ' + r.primary.model : ''} [${r.primary.availability.status}]`,
461
+ `Primary: ${r.primary.provider}${r.primary.model ? ' / ' + r.primary.model : ''} [${r.primary.availability.status}]`,
462
+ ));
463
+ } else log(t('기본 실행자: 미설정', 'Primary executor: unconfigured'));
464
+ log(t(`추천: ${r.decision.recommendedOptionId} (${r.decision.recommendationReason})`, `Recommended: ${r.decision.recommendedOptionId} (${r.decision.recommendationReason})`));
465
+ log('');
466
+ log(t('## 선택지', '## Choices'));
467
+ for (const option of r.options) {
468
+ const mark = option.selectable ? '✓' : '×';
469
+ const executor = option.executor && option.executor.provider
470
+ ? `${option.executor.provider}${option.executor.model ? ' / ' + option.executor.model : ''}`
471
+ : option.executor && option.executor.identitySource
472
+ ? `${option.executor.provider || 'unknown'} / ${option.executor.model || 'unknown'} (${option.executor.identitySource})`
473
+ : '-';
474
+ const blockers = option.availability && option.availability.blockingReasons && option.availability.blockingReasons.length
475
+ ? ` · blocked=${option.availability.blockingReasons.join(',')}` : '';
476
+ log(` ${mark} ${option.id} → ${executor}${blockers}`);
477
+ }
478
+ log('');
479
+ log(t('선택 명령:', 'Selection commands:'));
480
+ log(` leerness agents fallback provider ${JSON.stringify(task)} --role ${r.role} --to <provider> [--model <model>]`);
481
+ log(` leerness agents fallback session ${JSON.stringify(task)} --role ${r.role}`);
482
+ log(` leerness agents fallback direct ${JSON.stringify(task)} --role ${r.role}`);
483
+ log(` leerness agents fallback hold ${JSON.stringify(task)} --role ${r.role}`);
484
+ if (r.tier === 'high-risk') log(t(' 고위험 대체는 --approved-by "<승인자>"가 필요합니다.', ' High-risk substitution requires --approved-by "<approver>".'));
485
+ };
486
+
62
487
  if (!sub || sub === 'list') {
63
488
  // 1.9.157: Provider Registry 통합 — 빌트인 5종 + 사용자 정의 provider 포함
64
489
  const providers = _allProviders(root);
65
- const userIds = new Set(_readUserProviders(root).map(u => u.id));
490
+ const userIds = new Set(_readUserProviders(root).map(u => canonicalLegacyProviderIdentity(u && u.id)).filter(Boolean));
66
491
  const checks = providers.map(a => ({ ...(_checkAgent(a)), source: userIds.has(a.id) ? 'user' : 'builtin' }));
67
492
  if (has('--json')) { log(JSON.stringify({ agents: checks }, null, 2)); return; }
68
493
  log(t(`# 외부 AI CLI 오케스트레이션 (1.9.30)`, `# External AI CLI orchestration (1.9.30)`));
@@ -129,6 +554,314 @@ function agentsCmd(root, sub, args = [], deps = {}) {
129
554
  return;
130
555
  }
131
556
 
557
+ if (sub === 'availability') {
558
+ const found = _findAgentAction(args, new Set(['list', 'mark', 'set', 'clear', 'remove']));
559
+ const action = found.action || 'list';
560
+ if (action === 'list') {
561
+ const state = readAvailabilityObservations(root, parseInt(arg('--limit', '200'), 10));
562
+ if (has('--json')) {
563
+ log(JSON.stringify(state, null, 2));
564
+ if (state.ok === false || state.partial) process.exitCode = 1;
565
+ return state;
566
+ }
567
+ log(t(`# agents availability — active ${state.activeCount}`, `# agents availability — active ${state.activeCount}`));
568
+ for (const item of state.observations) {
569
+ const axes = [
570
+ item.authenticated && `auth=${item.authenticated}`,
571
+ item.modelEntitled && `entitlement=${item.modelEntitled}`,
572
+ item.quota && `quota=${item.quota}`,
573
+ item.policyAllowed && `policy=${item.policyAllowed}`,
574
+ item.reachable && `reachable=${item.reachable}`,
575
+ item.rateLimited && 'rate-limited=true',
576
+ ].filter(Boolean).join(' · ');
577
+ log(` ${item.provider}${item.model ? '/' + item.model : ''} — ${item.reason || 'manual-observation'}${axes ? ' · ' + axes : ''}${item.expiresAt ? ' · expires=' + item.expiresAt : ''}`);
578
+ }
579
+ if (state.ok === false || state.partial) { warn(state.error || state.code || 'availability history partial'); process.exitCode = 1; }
580
+ return state;
581
+ }
582
+ const positional = found.index >= 0 ? _parseAgentPositional(args, found.index + 1) : '';
583
+ const provider = (positional.split(/\s+/).filter(Boolean)[0] || arg('--to', null) || arg('--provider', null));
584
+ if (!provider) { failJson(has('--json'), 'availability_provider_required', 'agents availability mark|clear <provider> [--model <id>]'); return; }
585
+ try {
586
+ const written = (action === 'clear' || action === 'remove')
587
+ ? appendAvailabilityClear(root, { provider, model: arg('--model', null), reason: arg('--reason', 'manual-clear'), source: 'user-declared' }, { withLock: _withLock })
588
+ : appendAvailabilityObservation(root, {
589
+ provider,
590
+ model: arg('--model', null),
591
+ modelFamily: arg('--model-family', null),
592
+ reason: arg('--reason', null),
593
+ authenticated: arg('--auth-state', null),
594
+ modelEntitled: arg('--entitlement', null),
595
+ quota: arg('--quota-state', null),
596
+ policyAllowed: arg('--policy-state', null),
597
+ reachable: arg('--reachability', null),
598
+ rateLimited: has('--rate-limited') ? true : has('--not-rate-limited') ? false : null,
599
+ retryAfter: arg('--retry-after', null),
600
+ expiresAt: arg('--expires-at', null),
601
+ ttlMin: arg('--ttl-min', null),
602
+ source: 'user-declared',
603
+ }, { withLock: _withLock });
604
+ const state = readAvailabilityObservations(root, 2000);
605
+ if (state.ok === false || state.partial) {
606
+ const error = new Error(state.error || 'availability observation history is incomplete after append');
607
+ error.code = state.code || 'availability_history_partial';
608
+ throw error;
609
+ }
610
+ const out = { ok: true, action: action === 'clear' || action === 'remove' ? 'cleared' : 'marked', event: written.event, active: state.observations, file: written.file };
611
+ if (has('--json')) { log(JSON.stringify(out, null, 2)); return out; }
612
+ ok(t(`availability ${out.action}: ${provider}${arg('--model', null) ? '/' + arg('--model', null) : ''}`, `availability ${out.action}: ${provider}${arg('--model', null) ? '/' + arg('--model', null) : ''}`));
613
+ return out;
614
+ } catch (error) {
615
+ failJson(has('--json'), (error && error.code) || 'availability_write_failed', (error && error.message) || String(error));
616
+ return;
617
+ }
618
+ }
619
+
620
+ if (sub === 'resolve') {
621
+ const task = _taskArg();
622
+ const roleArg = arg('--role', null);
623
+ if (!task || !roleArg) { failJson(has('--json'), 'role_and_task_required', 'agents resolve "<task>" --role <role>'); return; }
624
+ const result = _roleResolution(roleArg, task);
625
+ if (!result.ok) { _failRoleResolution(result); return; }
626
+ const taskId = _generatedTaskId('task');
627
+ const audit = _commitRoleResolutionEvent(result, {
628
+ event: 'routing.proposed',
629
+ taskId,
630
+ task: task.slice(0, 300),
631
+ role: result.role,
632
+ tier: result.cls.tier,
633
+ policy: result.resolution.policy,
634
+ requestedExecutor: result.resolution.primary ? { provider: result.resolution.primary.provider, model: result.resolution.primary.model } : null,
635
+ decision: result.resolution.decision,
636
+ options: result.resolution.options.map(o => ({ id: o.id, kind: o.kind, executor: o.executor || null, selectable: o.selectable, requiresConfirmation: o.requiresConfirmation })),
637
+ executed: false,
638
+ });
639
+ if (!audit.ok) { failJson(has('--json'), audit.code || 'resolution_commit_failed', audit.error || 'role resolution could not be committed'); return; }
640
+ const out = { ok: true, task, taskId, role: result.role, classification: result.cls, resolution: result.resolution, auditWritten: audit.ok === true, auditEventId: audit.event.eventId, auditError: null };
641
+ if (has('--json')) { log(JSON.stringify(out, null, 2)); return out; }
642
+ _renderResolution(result, task);
643
+ log(` provenance: task=${taskId} · target=${audit.event.eventId}`);
644
+ if (!audit.ok) warn(`실행 이력 기록 실패: ${audit.error}`);
645
+ return out;
646
+ }
647
+
648
+ if (sub === 'fallback') {
649
+ const action = _findAgentAction(args, new Set(['primary', 'provider', 'model', 'session', 'session-takeover', 'takeover', 'direct', 'worker', 'session-direct', 'hold', 'defer', 'pause']));
650
+ const choice = action.action || '';
651
+ const task = action.index >= 0 ? (_parseAgentPositional(args, action.index + 1) || arg('--task', null)) : null;
652
+ const roleArg = arg('--role', null);
653
+ if (!choice || !task || !roleArg) { failJson(has('--json'), 'fallback_arguments_required', 'agents fallback provider|session|direct|hold "<task>" --role <role>'); return; }
654
+ const result = _roleResolution(roleArg, task);
655
+ if (!result.ok) { _failRoleResolution(result); return; }
656
+ const taskId = arg('--id', null) || _generatedTaskId('task');
657
+ const selection = selectFallbackOption(result.resolution, {
658
+ choice,
659
+ provider: arg('--to', null) || arg('--provider', null),
660
+ model: arg('--model', null),
661
+ approvedBy: arg('--approved-by', null),
662
+ });
663
+ if (!selection.ok) { failJson(has('--json'), selection.code, selection.error); return; }
664
+ const option = selection.option;
665
+ if (option.executor && option.executor.provider && ['primary', 'compatible-model', 'compatible-provider'].includes(option.kind)) {
666
+ const delegatedTask = (has('--raw') || typeof _harnessBrief !== 'function') ? task : (_harnessBrief() + task);
667
+ const optionDefinition = _allProviders(root).find(definition => definition.id === option.executor.provider) || null;
668
+ try { option.command = _dispatchCommand(option.executor.provider, delegatedTask, has('--write'), option.executor.model, optionDefinition); }
669
+ catch (error) { failJson(has('--json'), (error && error.code) || 'dispatch_command_invalid', (error && error.message) || 'dispatch command could not be prepared'); return; }
670
+ }
671
+ const eventName = option.kind === 'hold' ? 'task.held' : 'fallback.selected';
672
+ const audit = _commitRoleResolutionEvent(result, {
673
+ event: eventName,
674
+ task: task.slice(0, 300),
675
+ taskId,
676
+ role: result.role,
677
+ tier: result.cls.tier,
678
+ policy: result.resolution.policy,
679
+ requestedExecutor: result.resolution.primary ? { provider: result.resolution.primary.provider, model: result.resolution.primary.model } : null,
680
+ actualExecutor: option.executor || null,
681
+ substitution: {
682
+ occurred: option.kind !== 'primary' && option.kind !== 'hold',
683
+ kind: option.kind,
684
+ reason: result.resolution.primary && result.resolution.primary.availability
685
+ ? result.resolution.primary.availability.blockingReasons : ['primary-unconfigured'],
686
+ approvedBy: selection.approvedBy,
687
+ },
688
+ contract: option.contract || null,
689
+ executed: false,
690
+ });
691
+ if (!audit.ok) { failJson(has('--json'), audit.code, `fallback 선택을 기록하지 못해 적용하지 않았습니다: ${audit.error}`); return; }
692
+ const out = {
693
+ ok: true,
694
+ action: option.kind,
695
+ role: result.role,
696
+ tier: result.cls.tier,
697
+ selected: option,
698
+ command: option.command || null,
699
+ contract: option.contract ? { ...option.contract, task: task.slice(0, 300), session: option.executor || null } : null,
700
+ executed: false,
701
+ auditEventId: audit.event.eventId,
702
+ taskId,
703
+ };
704
+ if (has('--json')) { log(JSON.stringify(out, null, 2)); return out; }
705
+ if (option.command) {
706
+ log(`# provenance: task=${taskId} · target=${audit.event.eventId}`);
707
+ log(t(`# fallback 선택: ${option.kind} → ${option.executor.provider}${option.executor.model ? ' / ' + option.executor.model : ''}`, `# Fallback selected: ${option.kind} → ${option.executor.provider}${option.executor.model ? ' / ' + option.executor.model : ''}`));
708
+ log(option.command);
709
+ log(t(' ⓘ 명령만 준비했으며 아직 모델을 호출하지 않았습니다.', ' ⓘ Command prepared; no model has been called yet.'));
710
+ } else if (option.kind === 'hold') {
711
+ ok(t(`작업 보류 기록: 역할 ${result.role}`, `Work hold recorded: role ${result.role}`));
712
+ } else {
713
+ log(`# ${option.kind}`);
714
+ log(JSON.stringify(out.contract, null, 2));
715
+ log(t(' ⓘ 현재 채팅 Host가 이 계약을 받아 역할을 수행해야 합니다.', ' ⓘ The current chat host must consume this contract and perform the role.'));
716
+ }
717
+ return out;
718
+ }
719
+
720
+ if (sub === 'record') {
721
+ const action = _findAgentAction(args, new Set(['start', 'started', 'partial', 'complete', 'completed', 'fail', 'failed', 'review', 'reviewed', 'validate', 'validated']));
722
+ const rawEvent = action.action || '';
723
+ const eventMap = {
724
+ start: 'execution.started', started: 'execution.started',
725
+ partial: 'execution.partial', complete: 'execution.completed', completed: 'execution.completed',
726
+ fail: 'execution.failed', failed: 'execution.failed',
727
+ review: 'review.completed', reviewed: 'review.completed',
728
+ validate: 'validation.completed', validated: 'validation.completed',
729
+ };
730
+ const eventName = eventMap[rawEvent];
731
+ const summary = (action.index >= 0 ? _parseAgentPositional(args, action.index + 1) : '') || arg('--result', null) || arg('--summary', null);
732
+ if (!eventName || !summary) { failJson(has('--json'), 'record_arguments_required', 'agents record started|partial|completed|failed|reviewed|validated "<summary>" [--task T-ID]'); return; }
733
+ const session = sessionIdentityFromEnv(process.env);
734
+ const explicitProvider = arg('--to', null) || arg('--provider', null);
735
+ const explicitModel = arg('--model', null);
736
+ const explicitFamily = arg('--model-family', null);
737
+ const explicitSessionId = arg('--session-id', null);
738
+ const explicitIdentity = !!(explicitProvider || explicitModel || explicitFamily || explicitSessionId);
739
+ const explicitProviderIdentity = explicitProvider ? canonicalLegacyProviderIdentity(explicitProvider) : null;
740
+ const ambientProviderIdentity = session.provider ? canonicalLegacyProviderIdentity(session.provider) : null;
741
+ // MCP supplies both its host provider and the ambient session key. Preserve
742
+ // that known identity when the explicitly named provider is the same host;
743
+ // only a genuinely different executor authority loses ambient provenance.
744
+ const sameAmbientProvider = !explicitProvider
745
+ || !!(explicitProviderIdentity && ambientProviderIdentity
746
+ && explicitProviderIdentity === ambientProviderIdentity);
747
+ const sessionBoundCall = ['mcp-session-key', 'mcp-connection'].includes(session.identitySource);
748
+ const actualExecutor = normalizeExecutorIdentity({
749
+ provider: explicitProvider || session.provider,
750
+ model: explicitModel || (sameAmbientProvider ? session.model : null),
751
+ modelFamily: explicitFamily || (sameAmbientProvider ? session.modelFamily : null),
752
+ // An explicit MCP session key identifies the caller independently of the
753
+ // provider it reports. Dropping it lets one MCP caller manufacture two
754
+ // provider identities and certify its own work as independent.
755
+ sessionId: explicitSessionId || ((sameAmbientProvider || sessionBoundCall) ? session.sessionId : null),
756
+ identitySource: explicitIdentity ? 'user-declared' : session.identitySource,
757
+ }, { allowLegacyProviderIds: true });
758
+ const implementer = normalizeExecutorIdentity({
759
+ provider: arg('--agent', null),
760
+ model: arg('--agent-model', null),
761
+ modelFamily: arg('--agent-model-family', null),
762
+ sessionId: arg('--agent-session', null),
763
+ identitySource: 'user-declared',
764
+ }, { allowLegacyProviderIds: true });
765
+ const evidence = arg('--evidence', null);
766
+ const requestedRoleRaw = arg('--role', null);
767
+ const requestedRole = requestedRoleRaw == null ? null : _normalizeRole(requestedRoleRaw);
768
+ const reviewOfAttemptId = arg('--target', null);
769
+ const taskId = arg('--task', null);
770
+ const attemptId = arg('--id', null);
771
+ const terminal = ['execution.completed', 'execution.failed', 'review.completed', 'validation.completed'].includes(eventName);
772
+ const event = {
773
+ event: eventName,
774
+ taskId,
775
+ sessionId: actualExecutor.sessionId,
776
+ attemptId,
777
+ parentAttemptId: reviewOfAttemptId,
778
+ requestedRole,
779
+ role: requestedRole,
780
+ actualExecutor,
781
+ status: arg('--status', rawEvent),
782
+ result: { summary: String(summary).slice(0, 1000), evidence },
783
+ evidenceRefs: evidence ? [evidence] : [],
784
+ reviewOfAttemptId: eventName === 'review.completed' ? reviewOfAttemptId : null,
785
+ reviewerIndependent: null,
786
+ review: eventName === 'review.completed' ? {
787
+ reviewOfAttemptId,
788
+ // This is only a caller assertion. _commitTerminalEvent binds it to the
789
+ // referenced implementation event under the ledger lock, rejects a
790
+ // contradiction, and derives the authoritative identity/independence.
791
+ reviewOf: implementer,
792
+ reviewerIndependent: null,
793
+ reviewerIndependence: 'unverified',
794
+ independenceBasis: 'review-target-unresolved',
795
+ } : null,
796
+ executed: true,
797
+ };
798
+ let canonicalEvent;
799
+ try { canonicalEvent = normalizeExecutionEvent(event); }
800
+ catch (error) { failJson(has('--json'), (error && error.code) || 'provenance_event_invalid', (error && error.message) || String(error)); return; }
801
+ if (terminal) {
802
+ // Reject identifiers whose persisted value would differ from the caller's
803
+ // value (whitespace/control cleanup, secret redaction, or truncation). The
804
+ // same canonical bytes are then used for required-field and CAS checks.
805
+ const changed = [
806
+ ['--task', taskId, canonicalEvent.taskId],
807
+ ['--id', attemptId, canonicalEvent.attemptId],
808
+ ['--target', reviewOfAttemptId, canonicalEvent.parentAttemptId],
809
+ ['--role', requestedRoleRaw, canonicalEvent.requestedRole],
810
+ ['--provider/--to', explicitProvider, canonicalEvent.actualExecutor && canonicalEvent.actualExecutor.provider],
811
+ ['--model', explicitModel, canonicalEvent.actualExecutor && canonicalEvent.actualExecutor.model],
812
+ ['--model-family', explicitFamily, canonicalEvent.actualExecutor && canonicalEvent.actualExecutor.modelFamily],
813
+ ['--session-id', explicitSessionId, canonicalEvent.actualExecutor && canonicalEvent.actualExecutor.sessionId],
814
+ ].find(([, raw, normalized]) => raw != null && String(raw) !== normalized);
815
+ if (changed) {
816
+ failJson(has('--json'), 'invalid_provenance_identifier', `${changed[0]} must already be canonical, non-empty, and within 200 characters`);
817
+ return;
818
+ }
819
+ const missing = [];
820
+ if (!canonicalEvent.taskId) missing.push('--task');
821
+ if (!canonicalEvent.attemptId) missing.push('--id');
822
+ if (!canonicalEvent.actualExecutor || !canonicalEvent.actualExecutor.provider) missing.push('--provider/--to (or trusted session provider)');
823
+ if (!canonicalEvent.evidenceRefs.length) missing.push('--evidence');
824
+ if (['review.completed', 'validation.completed'].includes(eventName) && !canonicalEvent.parentAttemptId) missing.push('--target');
825
+ if (canonicalEvent.requestedRole && !canonicalEvent.parentAttemptId) missing.push('--target (role selection/attempt link)');
826
+ if (missing.length) {
827
+ failJson(has('--json'), 'provenance_fields_required', `terminal provenance requires ${missing.join(', ')}`);
828
+ return;
829
+ }
830
+ }
831
+ const audit = terminal ? _commitTerminalEvent(canonicalEvent) : _ledgerEvent(canonicalEvent);
832
+ if (!audit.ok) { failJson(has('--json'), audit.code, audit.error); return; }
833
+ const out = { ok: true, event: audit.event, file: audit.file };
834
+ if (has('--json')) { log(JSON.stringify(out, null, 2)); return out; }
835
+ ok(t(`execution provenance 기록: ${audit.event.event} · ${audit.event.eventId}`, `execution provenance recorded: ${audit.event.event} · ${audit.event.eventId}`));
836
+ log(` ${audit.file}`);
837
+ return out;
838
+ }
839
+
840
+ if (sub === 'history') {
841
+ const history = readExecutionEvents(root, parseInt(arg('--limit', '20'), 10));
842
+ if (history.ok === false && history.code !== 'ledger_invalid_lines') {
843
+ failJson(has('--json'), history.code || 'ledger_read_failed', history.error || 'execution ledger를 읽을 수 없습니다');
844
+ return history;
845
+ }
846
+ if (has('--json')) {
847
+ log(JSON.stringify(history, null, 2));
848
+ if (history.invalidLines) process.exitCode = 1;
849
+ return history;
850
+ }
851
+ log(t(`# agents history — 최근 ${history.events.length}건`, `# agents history — latest ${history.events.length} event(s)`));
852
+ for (const event of history.events) {
853
+ const who = event.actualExecutor && event.actualExecutor.provider
854
+ ? `${event.actualExecutor.provider}${event.actualExecutor.model ? '/' + event.actualExecutor.model : ''}` : '-';
855
+ log(` ${String(event.at || '').slice(0, 19)} ${String(event.event || '').padEnd(22)} ${String(event.role || '-').padEnd(11)} ${who}`);
856
+ }
857
+ if (history.invalidLines) {
858
+ warn(t(`해석하지 못한 ledger line ${history.invalidLines}건 (유효 이벤트는 보존)`, `${history.invalidLines} execution-ledger line(s) could not be parsed (valid events were preserved)`));
859
+ process.exitCode = 1;
860
+ }
861
+ if (history.truncated) log(t(' ⓘ 최근 4MB 범위만 읽었습니다.', ' ⓘ Only the latest 4 MB was read.'));
862
+ return history;
863
+ }
864
+
132
865
  // 1.36.78 (10차 헌트 F5): review-request 가 광고하는 `agents recommend <task>` 가 핸들러 부재로 usage(exit 1)만 뱉던 것.
133
866
  // 내부 _recommendAgent 로직을 정식 서브명령으로 노출 — 작업 텍스트/유형 → 적합 CLI + 이유.
134
867
  if (sub === 'recommend') {
@@ -148,7 +881,7 @@ function agentsCmd(root, sub, args = [], deps = {}) {
148
881
  // 1.36.91: `check` 는 **명시적 재확인** 명령이므로 인증 확인까지 수행한다(`list` 는 지연 때문에 하지 않는다).
149
882
  // 확인 가능한 provider 만 ok/no 로 판정하고 나머지는 unknown 으로 남긴다 — 설치됨을 사용가능이라 부르지 않는다.
150
883
  const providers = _allProviders(root);
151
- const userIds = new Set(_readUserProviders(root).map(u => u.id));
884
+ const userIds = new Set(_readUserProviders(root).map(u => canonicalLegacyProviderIdentity(u && u.id)).filter(Boolean));
152
885
  const checks = providers.map(a => ({ ...(_checkAgent(a, { auth: true })), source: userIds.has(a.id) ? 'user' : 'builtin' }));
153
886
  const authNote = '설치·활성은 확인했습니다. 인증은 무료·비대화형 확인 명령이 있는 provider 만 판정했고(auth: ok|no), 나머지는 unknown 입니다 — 쿼터·과금 권한은 어느 provider 도 확인하지 않았습니다.';
154
887
  if (has('--json')) {
@@ -179,13 +912,17 @@ function agentsCmd(root, sub, args = [], deps = {}) {
179
912
  if (sub === 'multi') {
180
913
  const task = _taskArg();
181
914
  if (!task) { fail('multi "<task>" 또는 --task 필요'); return process.exit(1); }
915
+ if (arg('--role', null)) {
916
+ failJson(has('--json'), 'role_multi_execution_unsupported', 'agents multi cannot enforce one role/model contract across multiple provider defaults; use agents resolve then dispatch/fallback');
917
+ return;
918
+ }
182
919
  // 1.36.29 (사용자 보고): dispatch 만 위임 브리프를 접두하고 multi(--execute 실 spawn 포함)는 원문 그대로 위임돼
183
920
  // 백그라운드 AI 가 leerness 프로토콜(handoff 적재/task 등록/evidence/session close)을 받지 못했다. dispatch 와 동일 규칙: --raw 로 옵트아웃.
184
921
  const briefTask = (has('--raw') || typeof _harnessBrief !== 'function') ? task : (_harnessBrief() + task);
185
922
  const onlyArg = arg('--only', null); // 'claude,codex' 처럼 콤마 구분 — 활성 중에서 추가 필터
186
923
  const writeMode = has('--write');
187
924
  const execute = has('--execute'); // 1.9.156: 명령 출력 → 실제 spawn + consensus 합의
188
- const checks = EXTERNAL_AGENTS.map(a => ({ def: a, status: _checkAgent(a) }));
925
+ const checks = _allProviders(root).map(a => ({ def: a, status: _checkAgent(a) }));
189
926
  let ready = checks.filter(x => x.status.status === 'ready');
190
927
  if (onlyArg) {
191
928
  const wanted = new Set(onlyArg.split(/[,\s]+/).filter(Boolean));
@@ -205,6 +942,23 @@ function agentsCmd(root, sub, args = [], deps = {}) {
205
942
  if (execute) {
206
943
  return (async () => {
207
944
  const timeout = parseInt(arg('--timeout', '60'), 10) * 1000;
945
+ const executionTaskId = arg('--id', null) || _generatedTaskId('multi');
946
+ const batchAttemptId = `attempt-${crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex')}`;
947
+ const batchAudit = _ledgerEvent({
948
+ event: 'execution.batch.started',
949
+ taskId: executionTaskId,
950
+ task: task.slice(0, 300),
951
+ attemptId: batchAttemptId,
952
+ role: arg('--role', null) ? _normalizeRole(arg('--role', null)) : null,
953
+ mode: writeMode ? 'write' : 'read-only',
954
+ result: { summary: 'agents multi --execute prepared', targets: ready.map(x => x.def.id) },
955
+ executed: false,
956
+ });
957
+ if (!batchAudit.ok) {
958
+ failJson(has('--json'), batchAudit.code || 'ledger_write_failed', `외부 모델 호출 전 실행 원장을 기록하지 못해 중단했습니다: ${batchAudit.error}`);
959
+ return;
960
+ }
961
+ const batchEventId = batchAudit.event.eventId;
208
962
  if (!has('--json')) {
209
963
  log(`# leerness agents multi --execute (1.9.156) — ${ready.length}개 활성 에이전트 병렬 호출`);
210
964
  log(`task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
@@ -217,19 +971,45 @@ function agentsCmd(root, sub, args = [], deps = {}) {
217
971
  // 병렬 _cliChat 호출 (sandbox 자동: runCommandSafe + env scrub + observability)
218
972
  const results = await Promise.all(ready.map(async ({ def }) => {
219
973
  const start = Date.now();
220
- const r = await _cliChat(root, def.id, briefTask, { timeout });
221
- return {
974
+ let r;
975
+ try { r = await _cliChat(root, def.id, briefTask, { timeout }); }
976
+ catch (error) { r = { ok: false, response: '', error: error && error.message ? error.message : String(error), executed: false }; }
977
+ const result = {
222
978
  agent: def.id,
979
+ model: null,
980
+ identitySource: 'cli-default-unverified',
981
+ executed: r.executed !== false,
223
982
  elapsed: Date.now() - start,
224
- ok: r.ok,
983
+ ok: !!r.ok,
225
984
  response: r.response || '',
226
985
  error: r.error || null,
227
- responseTokens: Math.ceil((r.response || '').length / 4) // 대략 token 추정
986
+ responseTokens: Math.ceil((r.response || '').length / 4),
228
987
  };
988
+ const resultAudit = _ledgerEvent({
989
+ event: result.ok ? 'execution.completed' : 'execution.failed',
990
+ taskId: executionTaskId,
991
+ parentAttemptId: batchEventId,
992
+ attemptId: `${batchAttemptId}:${def.id}`,
993
+ task: task.slice(0, 300),
994
+ role: arg('--role', null) ? _normalizeRole(arg('--role', null)) : null,
995
+ actualExecutor: normalizeExecutorIdentity({ provider: def.id, model: null, identitySource: result.identitySource }),
996
+ mode: writeMode ? 'write' : 'read-only',
997
+ status: result.ok ? 'completed' : 'failed',
998
+ result: {
999
+ summary: result.ok ? 'agents multi --execute completed' : `agents multi --execute failed: ${result.error || 'unknown error'}`,
1000
+ evidence: `elapsedMs=${result.elapsed}; responseChars=${result.response.length}`,
1001
+ },
1002
+ executed: result.executed,
1003
+ });
1004
+ result.auditWritten = resultAudit.ok === true;
1005
+ result.auditEventId = resultAudit.ok === true ? resultAudit.event.eventId : null;
1006
+ result.auditError = resultAudit.ok === true ? null : resultAudit.error;
1007
+ return result;
229
1008
  }));
230
1009
  const totalElapsed = Date.now() - t0;
231
1010
  const ok = results.filter(r => r.ok);
232
1011
  const failures = results.filter(r => !r.ok);
1012
+ const auditFailures = results.filter(r => !r.auditWritten);
233
1013
  _recordRun(root, { kind: 'agents_multi_execute', count: ready.length, success: ok.length, durationMs: totalElapsed, task: task.slice(0, 200) });
234
1014
  // 1.9.155 consensus 로직 재사용 — multi-signal scoring (tokens + overlap + lengthFit)
235
1015
  let best = null, scored = [];
@@ -262,8 +1042,10 @@ function agentsCmd(root, sub, args = [], deps = {}) {
262
1042
  task, count: ready.length, success: ok.length, totalElapsedMs: totalElapsed,
263
1043
  results: scored.length ? scored : results,
264
1044
  best: best ? { agent: best.agent, score: best.score, response: best.response } : null,
265
- failures
1045
+ failures,
1046
+ provenance: { taskId: executionTaskId, batchEventId, complete: !!executionTaskId && auditFailures.length === 0, failedWrites: auditFailures.map(r => ({ agent: r.agent, error: r.auditError })) },
266
1047
  }, null, 2));
1048
+ if (auditFailures.length) process.exitCode = 1;
267
1049
  return;
268
1050
  }
269
1051
  log(`\n## 결과: ${ok.length}/${ready.length} 성공 · 총 ${totalElapsed}ms (병렬)`);
@@ -302,6 +1084,10 @@ function agentsCmd(root, sub, args = [], deps = {}) {
302
1084
  } catch {}
303
1085
  }
304
1086
  }
1087
+ if (auditFailures.length) {
1088
+ warn(`실행 provenance 기록 실패 ${auditFailures.length}건 — 결과를 완료 증거로 사용하지 마세요.`);
1089
+ process.exitCode = 1;
1090
+ }
305
1091
  if (failures.length && !best) {
306
1092
  process.exitCode = 1;
307
1093
  }
@@ -311,7 +1097,7 @@ function agentsCmd(root, sub, args = [], deps = {}) {
311
1097
  log(JSON.stringify({
312
1098
  task, count: ready.length,
313
1099
  agents: ready.map(x => ({ id: x.def.id, version: x.status.version })),
314
- commands: ready.map(x => _dispatchCommand(x.def.id, briefTask, writeMode)),
1100
+ commands: ready.map(x => _dispatchCommand(x.def.id, briefTask, writeMode, null, x.def)),
315
1101
  // 1.9.266 (UR-0021 2단계): 각 에이전트 슬래시 명령 힌트 — sub-agent 가 알맞은 슬래시 사용
316
1102
  slashCommands: ready.reduce((acc, x) => { const h = _agentSlashHint(root, x.def.id); if (h) acc[x.def.id] = { invoke: h.invoke, commands: h.commands.map(c => c.cmd) }; return acc; }, {})
317
1103
  }, null, 2));
@@ -327,7 +1113,7 @@ function agentsCmd(root, sub, args = [], deps = {}) {
327
1113
  for (const { def, status } of ready) {
328
1114
  log(`### [${def.id}] (v${status.version || '?'})`);
329
1115
  log('```sh');
330
- log(_dispatchCommand(def.id, briefTask, writeMode));
1116
+ log(_dispatchCommand(def.id, briefTask, writeMode, null, def));
331
1117
  log('```');
332
1118
  // 1.9.266 (UR-0021 2단계): 에이전트별 슬래시 명령 힌트
333
1119
  try {
@@ -354,48 +1140,172 @@ function agentsCmd(root, sub, args = [], deps = {}) {
354
1140
  }
355
1141
  // 1.9.270: --role <role> — 설정된 역할 → provider+model 라우팅 (--to 없을 때)
356
1142
  const roleArg = arg('--role', null);
1143
+ const roleTaskId = roleArg ? _generatedTaskId('task') : null;
357
1144
  let roleModel = arg('--model', null);
358
1145
  let rolePersona = '';
1146
+ let resolvedRole = null;
1147
+ let roleResolutionResult = null;
1148
+ if (roleArg) {
1149
+ roleResolutionResult = _roleResolution(roleArg, task);
1150
+ if (!roleResolutionResult.ok) {
1151
+ _failRoleResolution(roleResolutionResult);
1152
+ return;
1153
+ }
1154
+ resolvedRole = roleResolutionResult.resolved;
1155
+ }
359
1156
  if (roleArg && !target) {
360
- const resolved = _resolveRole(root, roleArg);
1157
+ const resolved = resolvedRole;
361
1158
  if (!resolved) { fail(`역할 미설정: ${_normalizeRole(roleArg)} — leerness roles set ${_normalizeRole(roleArg)} --provider <id> 또는 roles suggest --apply`); return process.exit(1); }
362
1159
  target = resolved.provider;
363
1160
  if (!roleModel) roleModel = resolved.model;
364
1161
  rolePersona = resolved.persona || '';
365
- log(`🎭 역할 ${_normalizeRole(roleArg)} → ${target}${roleModel ? ' / ' + roleModel : ''}`);
366
- if (rolePersona) log(` persona: ${rolePersona}`);
1162
+ if (!has('--json')) {
1163
+ log(`🎭 역할 ${_normalizeRole(roleArg)} → ${target}${roleModel ? ' / ' + roleModel : ''}`);
1164
+ if (rolePersona) log(` persona: ${rolePersona}`);
1165
+ }
367
1166
  }
368
1167
  if (!target) { fail('--to <agent_id> 또는 --role <role> 필요 (claude/codex/agy/grok/copilot) — 활성 전체 일괄은 `leerness agents multi`'); return process.exit(1); }
369
- const agentDef = EXTERNAL_AGENTS.find(a => a.id === target);
1168
+ const agentDef = _allProviders(root).find(a => a.id === target);
370
1169
  if (!agentDef) { fail(`알 수 없는 agent: ${target}`); return process.exit(1); }
371
1170
  // 1.9.36: 작업 유형 키워드 분석 → 최적 CLI 추천 (ready 체크 전에 출력 — 비활성이어도 추천)
372
1171
  const recommendation = _recommendAgent(task);
373
1172
  const recommended = recommendation.target;
374
1173
  if (recommended && recommended !== target) {
375
- log(`💡 추천: 이 작업은 ${recommended}가 더 적합 (${recommendation.reason})`);
1174
+ if (!has('--json')) log(`💡 추천: 이 작업은 ${recommended}가 더 적합 (${recommendation.reason})`);
376
1175
  }
377
1176
  const status = _checkAgent(agentDef);
378
1177
  if (status.status !== 'ready') {
379
- fail(`${target} 비활성 (${status.status}). 환경변수 ${agentDef.envFlag}=1 + CLI 설치 필요.`);
380
- return process.exit(1);
1178
+ if (roleArg && resolvedRole) {
1179
+ const result = roleResolutionResult || _roleResolution(roleArg, task);
1180
+ const audit = result.ok ? _commitRoleResolutionEvent(result, {
1181
+ event: 'routing.blocked', task: task.slice(0, 300), role: result.role, tier: result.cls.tier,
1182
+ taskId: roleTaskId,
1183
+ requestedExecutor: { provider: target, model: roleModel || null },
1184
+ reason: status.status, decision: result.resolution.decision, executed: false,
1185
+ }) : { ok: false, error: result.error };
1186
+ if (has('--json')) {
1187
+ log(JSON.stringify({ ok: false, code: 'role_executor_unavailable', taskId: roleTaskId, role: _normalizeRole(roleArg), target, status, resolution: result.ok ? result.resolution : null, auditWritten: audit.ok === true, auditEventId: audit.ok === true ? audit.event.eventId : null, auditError: audit.ok === true ? null : audit.error }, null, 2));
1188
+ process.exitCode = 1;
1189
+ return;
1190
+ }
1191
+ fail(`${target} 비활성 (${status.status}). 역할을 유지한 대체 선택지를 표시합니다.`);
1192
+ if (result.ok) _renderResolution(result, task);
1193
+ if (!audit.ok) warn(`실행 이력 기록 실패: ${audit.error}`);
1194
+ process.exitCode = 1;
1195
+ return;
1196
+ }
1197
+ failJson(has('--json'), 'agent_unavailable', `${target} 비활성 (${status.status}). 환경변수 ${agentDef.envFlag}=1 + CLI 설치 필요.`);
1198
+ return;
1199
+ }
1200
+ let selectedRoleOption = null;
1201
+ if (roleArg && roleResolutionResult) {
1202
+ const primary = roleResolutionResult.resolution.primary;
1203
+ const primarySelected = !!(primary
1204
+ && target === primary.provider
1205
+ && (!roleModel || roleModel === primary.model));
1206
+ const selection = selectFallbackOption(roleResolutionResult.resolution, {
1207
+ choice: primarySelected ? 'primary' : 'provider',
1208
+ provider: target,
1209
+ model: roleModel,
1210
+ approvedBy: arg('--approved-by', null),
1211
+ });
1212
+ if (!selection.ok) {
1213
+ const blocked = {
1214
+ ok: false,
1215
+ code: selection.code,
1216
+ error: selection.error,
1217
+ role: roleResolutionResult.role,
1218
+ target,
1219
+ model: roleModel || null,
1220
+ resolution: roleResolutionResult.resolution,
1221
+ };
1222
+ if (has('--json')) log(JSON.stringify(blocked, null, 2));
1223
+ else {
1224
+ fail(selection.error);
1225
+ _renderResolution(roleResolutionResult, task);
1226
+ }
1227
+ process.exitCode = 1;
1228
+ return;
1229
+ }
1230
+ selectedRoleOption = selection.option;
1231
+ if (selectedRoleOption.executor) {
1232
+ target = selectedRoleOption.executor.provider;
1233
+ roleModel = selectedRoleOption.executor.model || null;
1234
+ }
1235
+ }
1236
+ if (roleModel && !isValidModelIdentifier(roleModel)) {
1237
+ failJson(has('--json'), 'invalid_model_identifier', 'model is not a safe model identifier');
1238
+ return;
381
1239
  }
382
1240
  // 1.9.36: --write 시 파일 수정 가능 권장 플래그 자동 첨부, 미명시 시 read-only 안전 모드
383
1241
  const writeMode = has('--write');
384
1242
  const readOnly = has('--readonly') || !writeMode;
385
1243
  // 실제 호출은 안 함 — 프롬프트만 생성 (사용자가 명시적으로 실행)
386
- log(`# leerness agents dispatch (1.9.36)`);
387
- log(`대상: ${target} (${agentDef.bin})`);
388
- log(`상태: 🟢 ready, 버전 ${status.version || '?'}`);
389
- log(`모드: ${writeMode ? '✏ write (파일 수정 가능)' : '🔒 read-only (분석 전용, 안전)'}`);
390
- log('');
391
- log(`## 실행 명령 (사용자가 복사해서 실행)`);
392
- if (roleModel) log(`# 🎭 모델: ${roleModel} (역할 기반 라우팅, 1.9.270)`);
393
- log('');
1244
+ if (!has('--json')) {
1245
+ log(`# leerness agents dispatch (1.9.36)`);
1246
+ log(`대상: ${target} (${agentDef.bin})`);
1247
+ log(`상태: 🟢 ready, 버전 ${status.version || '?'}`);
1248
+ log(`모드: ${writeMode ? '✏ write (파일 수정 가능)' : '🔒 read-only (분석 전용, 안전)'}`);
1249
+ log('');
1250
+ log(`## 실행 명령 (사용자가 복사해서 실행)`);
1251
+ if (roleModel) log(`# 🎭 모델: ${roleModel} (역할 기반 라우팅, 1.9.270)`);
1252
+ log('');
1253
+ }
394
1254
  // 1.9.270: _dispatchCommand 로 통일 (roleModel 주입) — 명령 빌더 단일화
395
1255
  // 1.35.6 (18th 위임실증): harness 계약 브리프 자동 접두 — codex 실측(0.141)에서 AGENTS.md 로드 시 준수는 확인됐으나,
396
1256
  // cwd 가 프로젝트 루트가 아니거나 AGENTS.md 미지원 CLI(aider/qwen 등)면 계약이 전달되지 않음 → 프롬프트 접두가 안전망. --raw 로 원문 위임.
397
1257
  const dispatchTask = (has('--raw') || typeof _harnessBrief !== 'function') ? task : (_harnessBrief() + task);
398
- log(_dispatchCommand(target, dispatchTask, writeMode, roleModel));
1258
+ let preparedCommand;
1259
+ const dispatchDefinition = target === agentDef.id
1260
+ ? agentDef
1261
+ : (_allProviders(root).find(definition => definition.id === target) || null);
1262
+ try { preparedCommand = _dispatchCommand(target, dispatchTask, writeMode, roleModel, dispatchDefinition); }
1263
+ catch (error) { failJson(has('--json'), (error && error.code) || 'dispatch_command_invalid', (error && error.message) || 'dispatch command could not be prepared'); return; }
1264
+ const requestedExecutor = resolvedRole ? normalizeExecutorIdentity({
1265
+ provider: resolvedRole.provider,
1266
+ model: resolvedRole.model || null,
1267
+ modelFamily: resolvedRole.modelFamily || null,
1268
+ identitySource: 'role-config',
1269
+ }, { allowLegacyProviderIds: true }) : null;
1270
+ const actualExecutor = selectedRoleOption && selectedRoleOption.executor
1271
+ ? normalizeExecutorIdentity({ ...selectedRoleOption.executor, identitySource: 'role-resolution' }, { allowLegacyProviderIds: true })
1272
+ : normalizeExecutorIdentity({ provider: target, model: roleModel || null, identitySource: roleArg ? 'role-config' : 'user-declared' }, { allowLegacyProviderIds: true });
1273
+ const substituted = !!(requestedExecutor && (requestedExecutor.provider !== actualExecutor.provider || (requestedExecutor.model || null) !== (actualExecutor.model || null)));
1274
+ const audit = roleArg && roleResolutionResult
1275
+ ? _commitRoleResolutionEvent(roleResolutionResult, {
1276
+ event: 'dispatch.prepared',
1277
+ taskId: roleTaskId,
1278
+ task: task.slice(0, 300),
1279
+ role: roleArg ? _normalizeRole(roleArg) : null,
1280
+ requestedExecutor,
1281
+ actualExecutor,
1282
+ substitution: { occurred: substituted, kind: substituted ? 'explicit-provider-or-model' : 'none' },
1283
+ mode: writeMode ? 'write' : 'read-only',
1284
+ commandPrepared: true,
1285
+ executed: false,
1286
+ })
1287
+ : _ledgerEvent({
1288
+ event: 'dispatch.prepared',
1289
+ task: task.slice(0, 300),
1290
+ role: null,
1291
+ requestedExecutor: null,
1292
+ actualExecutor,
1293
+ substitution: { occurred: false, kind: 'none' },
1294
+ mode: writeMode ? 'write' : 'read-only',
1295
+ commandPrepared: true,
1296
+ executed: false,
1297
+ });
1298
+ if (roleArg && !audit.ok) {
1299
+ failJson(has('--json'), audit.code || 'ledger_write_failed', `역할 실행 명령을 기록하지 못해 준비를 중단했습니다: ${audit.error}`);
1300
+ return;
1301
+ }
1302
+ if (has('--json')) {
1303
+ log(JSON.stringify({ ok: true, action: 'dispatch-prepared', taskId: roleTaskId, role: roleArg ? _normalizeRole(roleArg) : null, target, model: roleModel || null, status, mode: writeMode ? 'write' : 'read-only', command: preparedCommand, executed: false, auditWritten: audit.ok === true, auditEventId: audit.ok === true ? audit.event.eventId : null, auditError: audit.ok === true ? null : audit.error }, null, 2));
1304
+ return;
1305
+ }
1306
+ log(preparedCommand);
1307
+ if (roleArg && audit.ok) log(`# provenance: task=${roleTaskId} · target=${audit.event.eventId}`);
1308
+ if (!audit.ok) warn(`실행 이력 기록 실패: ${audit.error}`);
399
1309
  if (!has('--raw') && typeof _harnessBrief === 'function') log(`# ℹ harness 위임 브리프 자동 접두 (1.35.6) — 원문만 위임하려면 --raw`);
400
1310
  if (target === 'claude' && writeMode) log(`# ⚠ --dangerously-skip-permissions: 도구 권한 자동 승인 (파일 수정 가능)`);
401
1311
  if (target === 'codex') { log(`# ℹ codex는 PowerShell 경유 — POSIX /tmp 경로는 C:\\tmp\\로 해석됨`); log(`# ⚠ 비대화형 spawn 시 stdin 을 닫고 실행 — 열린 파이프면 codex 가 'Reading additional input from stdin...' EOF 대기로 hang (1.35.6 실측)`); if (writeMode) log(`# ⚠ --dangerously-bypass-approvals-and-sandbox: sandbox 우회`); }
@@ -431,18 +1341,58 @@ function agentsCmd(root, sub, args = [], deps = {}) {
431
1341
  // 1.9.36: 같은 prompt를 ready CLI 모두에 동시 호출 + 시간/응답 길이/exit code 비교
432
1342
  const task = _taskArg();
433
1343
  if (!task) { fail('bench "<task>" 필요'); return process.exit(1); }
1344
+ if (arg('--role', null)) {
1345
+ failJson(has('--json'), 'role_benchmark_execution_unsupported', 'agents bench uses provider-default models and cannot prove a role/model contract; use agents resolve then dispatch/fallback');
1346
+ return;
1347
+ }
434
1348
  const timeoutS = parseInt(arg('--timeout', '60'), 10);
435
1349
  const writeMode = has('--write');
1350
+ const benchTaskId = arg('--id', null) || _generatedTaskId('bench');
436
1351
  // Disabled providers are not bench candidates. Filter before _checkAgent so a
437
1352
  // bench that has no opted-in provider cannot execute installed third-party
438
1353
  // CLIs merely to discover that they are disabled.
439
- const ready = EXTERNAL_AGENTS.filter(a => process.env[a.envFlag] === '1')
440
- .map(a => ({ agent: a, status: _checkAgent(a) }))
441
- .filter(x => x.status.status === 'ready');
1354
+ const benchProviders = typeof _allProviders === 'function' ? _allProviders(root) : EXTERNAL_AGENTS;
1355
+ const ready = benchProviders.filter(a => process.env[a.envFlag] === '1')
1356
+ .map(a => ({ agent: a, status: _checkAgent(a) }))
1357
+ .filter(x => x.status.status === 'ready');
442
1358
  if (!ready.length) {
443
1359
  fail('ready CLI 없음 — leerness setup-agents 또는 .env에 LEERNESS_ENABLE_X=1 설정 필요');
444
1360
  return process.exit(1);
445
1361
  }
1362
+ const benchBatchAudit = _ledgerEvent({
1363
+ event: 'benchmark.batch.started',
1364
+ taskId: benchTaskId,
1365
+ task: task.slice(0, 300),
1366
+ attemptId: `attempt-${crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex')}`,
1367
+ role: arg('--role', null) ? _normalizeRole(arg('--role', null)) : null,
1368
+ mode: writeMode ? 'write' : 'read-only',
1369
+ result: { summary: 'agents bench prepared', targets: ready.map(x => x.agent.id) },
1370
+ executed: false,
1371
+ });
1372
+ if (!benchBatchAudit.ok) {
1373
+ failJson(has('--json'), benchBatchAudit.code || 'ledger_write_failed', `외부 모델 호출 전 benchmark 원장을 기록하지 못해 중단했습니다: ${benchBatchAudit.error}`);
1374
+ return;
1375
+ }
1376
+ const benchBatchEventId = benchBatchAudit.event.eventId;
1377
+ const _recordBenchResult = (agent, entry, executed) => {
1378
+ const audit = _ledgerEvent({
1379
+ event: entry.ok ? 'benchmark.completed' : 'benchmark.failed',
1380
+ taskId: benchTaskId,
1381
+ parentAttemptId: benchBatchEventId,
1382
+ attemptId: `${benchBatchAudit.event.attemptId || benchBatchEventId}:${agent.id}`,
1383
+ task: task.slice(0, 300),
1384
+ role: arg('--role', null) ? _normalizeRole(arg('--role', null)) : null,
1385
+ actualExecutor: normalizeExecutorIdentity({ provider: agent.id, model: null, identitySource: 'cli-default-unverified' }),
1386
+ mode: writeMode ? 'write' : 'read-only',
1387
+ status: entry.ok ? 'completed' : 'failed',
1388
+ result: {
1389
+ summary: entry.ok ? 'agents bench completed' : `agents bench failed: ${entry.error || `exit ${entry.exit}`}`,
1390
+ evidence: `elapsedMs=${entry.elapsed}; responseChars=${String(entry.stdout || '').length}`,
1391
+ },
1392
+ executed: !!executed,
1393
+ });
1394
+ return { ...entry, model: null, identitySource: 'cli-default-unverified', auditWritten: audit.ok === true, auditEventId: audit.ok === true ? audit.event.eventId : null, auditError: audit.ok === true ? null : audit.error };
1395
+ };
446
1396
  log(`# leerness agents bench (1.9.36)`);
447
1397
  log(`task: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}`);
448
1398
  log(`참여 CLI: ${ready.map(r => r.agent.id).join(', ')} (${ready.length}개)`);
@@ -455,7 +1405,7 @@ function agentsCmd(root, sub, args = [], deps = {}) {
455
1405
  const t0 = Date.now();
456
1406
  const spec = _benchLaunchSpec(agent, task, writeMode);
457
1407
  if (spec.unsupported) {
458
- results.push({ id: agent.id, exit: null, elapsed: 0, stdout: '', stderrLen: 0, error: spec.unsupported, ok: false });
1408
+ results.push(_recordBenchResult(agent, { id: agent.id, exit: null, elapsed: 0, stdout: '', stderrLen: 0, error: spec.unsupported, ok: false }, false));
459
1409
  resolve();
460
1410
  return;
461
1411
  }
@@ -465,7 +1415,7 @@ function agentsCmd(root, sub, args = [], deps = {}) {
465
1415
  try {
466
1416
  r = _spawnPortable(spec.file, spec.args, { stdio: [spec.stdin, 'pipe', 'pipe'] });
467
1417
  } catch (err) {
468
- results.push({ id: agent.id, exit: -1, elapsed: Date.now() - t0, stdout: '', stderrLen: 0, error: err.message, ok: false });
1418
+ results.push(_recordBenchResult(agent, { id: agent.id, exit: -1, elapsed: Date.now() - t0, stdout: '', stderrLen: 0, error: err.message, ok: false }, false));
469
1419
  resolve();
470
1420
  return;
471
1421
  }
@@ -474,11 +1424,14 @@ function agentsCmd(root, sub, args = [], deps = {}) {
474
1424
  r.stderr.on('data', d => { stderr += d; });
475
1425
  const timer = setTimeout(() => { r.kill(); }, timeoutS * 1000);
476
1426
  let settled = false;
477
- const settle = (entry) => {
1427
+ const settle = (entry, executed) => {
478
1428
  if (settled) return;
479
1429
  settled = true;
480
1430
  clearTimeout(timer);
481
- results.push(entry);
1431
+ // ChildProcess commonly emits `error` and then `close`. Create the
1432
+ // provenance event only after winning this settle boundary so one
1433
+ // invocation can never append two terminal events.
1434
+ results.push(_recordBenchResult(agent, entry, executed));
482
1435
  resolve();
483
1436
  };
484
1437
  r.on('close', (code) => {
@@ -487,15 +1440,20 @@ function agentsCmd(root, sub, args = [], deps = {}) {
487
1440
  id: agent.id, exit: code, elapsed,
488
1441
  stdout: stdout.trim().split('\n').slice(-3).join('\n'),
489
1442
  stderrLen: stderr.length,
490
- ok: code === 0 && stdout.trim().length > 0
491
- });
1443
+ ok: code === 0 && stdout.trim().length > 0,
1444
+ }, true);
492
1445
  });
493
1446
  r.on('error', (err) => {
494
- settle({ id: agent.id, exit: -1, elapsed: Date.now() - t0, stdout: '', stderrLen: 0, error: err.message, ok: false });
1447
+ settle({ id: agent.id, exit: -1, elapsed: Date.now() - t0, stdout: '', stderrLen: 0, error: err.message, ok: false }, false);
495
1448
  });
496
1449
  }));
497
1450
  return Promise.all(promises).then(() => {
498
- if (has('--json')) { log(JSON.stringify({ task, results }, null, 2)); return results; }
1451
+ const auditFailures = results.filter(r => !r.auditWritten);
1452
+ if (has('--json')) {
1453
+ log(JSON.stringify({ task, results, provenance: { taskId: benchTaskId, batchEventId: benchBatchEventId, complete: !!benchTaskId && auditFailures.length === 0, failedWrites: auditFailures.map(r => ({ agent: r.id, error: r.auditError })) } }, null, 2));
1454
+ if (auditFailures.length) process.exitCode = 1;
1455
+ return results;
1456
+ }
499
1457
  log(`| CLI | 시간 | exit | 응답 길이 | 마지막 라인 |`);
500
1458
  log(`|---|---:|---:|---:|---|`);
501
1459
  // sort by elapsed
@@ -510,74 +1468,252 @@ function agentsCmd(root, sub, args = [], deps = {}) {
510
1468
  log(`결과: ${okCount}/${results.length} 성공`);
511
1469
  const fastest = results.filter(r => r.ok).sort((a, b) => a.elapsed - b.elapsed)[0];
512
1470
  if (fastest) log(`🏆 가장 빠름: ${fastest.id} (${fastest.elapsed}ms)`);
1471
+ if (auditFailures.length) {
1472
+ warn(`benchmark provenance 기록 실패 ${auditFailures.length}건 — 결과를 완료 증거로 사용하지 마세요.`);
1473
+ process.exitCode = 1;
1474
+ }
513
1475
  return results;
514
1476
  });
515
1477
  }
516
1478
 
517
1479
  if (sub === 'quota') {
518
- // 1.9.31: 각 CLI 사용량/쿼터 추정 + provider 대시보드 링크
519
- const results = [];
520
- for (const agent of EXTERNAL_AGENTS) {
521
- const base = _checkAgent(agent);
522
- const out = { id: agent.id, bin: agent.bin, status: base.status, quota: null, hint: null, raw: null };
523
- if (base.status !== 'ready') {
524
- out.hint = base.status === 'not-installed' ? `${agent.bin} CLI 미설치` : base.status === 'disabled' ? `${agent.envFlag}=1 필요` : '알 수 없음';
525
- results.push(out); continue;
526
- }
527
- // CLI별 quota 탐지 시도
528
- try {
529
- if (agent.id === 'claude') {
530
- // claude는 /status 슬래시 (대화형)만 지원. 비대화형 추정 불가.
531
- out.quota = 'unknown';
532
- out.hint = '대화 내 `/status` 슬래시 또는 https://console.anthropic.com/settings/usage 확인';
533
- } else if (agent.id === 'codex') {
534
- // codex CLI: codex --help에 usage 명령 있는지 확인
535
- const r = spawnPortableSync(agent.bin, ['--help'], { encoding: 'utf8', timeout: 4000 });
536
- const help = (r.stdout || r.stderr || '').toLowerCase();
537
- if (help.includes('usage') || help.includes('quota')) {
538
- out.quota = 'cli-supported';
539
- out.hint = '`codex usage` 또는 `codex quota` 시도 가능';
540
- } else {
541
- out.quota = 'unknown';
542
- out.hint = 'https://platform.openai.com/account/usage 확인';
543
- }
544
- out.raw = help.slice(0, 200);
545
- } else if (agent.id === 'agy') {
546
- // agy CLI (Antigravity): 무료 티어는 분당 60req 제한, CLI 자체에선 노출 안 됨
547
- out.quota = 'rate-limited';
548
- out.hint = '무료 티어: 60 req/min, 1000 req/day · Antigravity 유료 플랜은 https://antigravity.google.com';
549
- } else if (agent.id === 'copilot') {
550
- // gh copilot은 GitHub Copilot 구독 (월 단위 quota 없음, individual/business 플랜)
551
- const r = spawnPortableSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 4000 });
552
- const authed = r.status === 0;
553
- out.quota = authed ? 'subscription' : 'not-authed';
554
- out.hint = authed ? 'Copilot 구독자 무제한 (월 플랜) · https://github.com/settings/copilot' : '`gh auth login` 필요';
555
- }
556
- } catch (e) {
557
- out.quota = 'error';
558
- out.hint = e.message;
1480
+ // Provider capacity observation: installation, enablement, authentication, routing eligibility,
1481
+ // model callability, and remaining capacity are separate facts. Unknown values stay unknown.
1482
+ // This command may load the project .env for Leerness enable flags, but it does not emit, persist,
1483
+ // or use credential values to infer quota. Only registered, safe, non-interactive auth checks run,
1484
+ // and only for providers that are both enabled and installed.
1485
+ const availabilityState = readAvailabilityObservations(root, 2000);
1486
+ if (availabilityState.ok === false || availabilityState.partial) {
1487
+ failJson(has('--json'), availabilityState.code || 'availability_history_partial', availabilityState.error || 'availability observation history is incomplete');
1488
+ return;
1489
+ }
1490
+ const providers = _allProviders(root);
1491
+ const userIds = new Set(_readUserProviders(root).map(u => canonicalLegacyProviderIdentity(u && u.id)).filter(Boolean));
1492
+ const attemptedAuthChecks = [];
1493
+ const attemptedVersionChecks = [];
1494
+ const presenceOnlyChecks = [];
1495
+ const limitationCodes = [
1496
+ 'local_prerequisites_only',
1497
+ 'authentication_is_not_model_entitlement',
1498
+ 'exact_capacity_requires_verified_official_adapter',
1499
+ 'recorded_availability_is_not_official_capacity',
1500
+ 'disabled_providers_presence_only',
1501
+ 'credential_values_not_passed_to_probe_commands',
1502
+ ];
1503
+ const humanLimitations = [
1504
+ t(
1505
+ 'CLI 설치·활성 상태와 등록된 안전한 인증 확인만 관측합니다.',
1506
+ 'Only CLI installation, enablement, and registered safe authentication checks are observed.',
1507
+ ),
1508
+ t(
1509
+ '인증 성공은 특정 모델 호출 권한이나 잔여량을 증명하지 않습니다.',
1510
+ 'Successful authentication does not prove model entitlement or remaining capacity.',
1511
+ ),
1512
+ t(
1513
+ '정확한 잔여량은 공급자가 공식 비대화형 계약을 제공하고 별도 어댑터가 검증된 경우에만 수치화할 수 있습니다.',
1514
+ 'Exact remaining capacity can be reported only when a provider exposes an official non-interactive contract and a dedicated adapter is verified.',
1515
+ ),
1516
+ t(
1517
+ 'availability 명령으로 기록한 상태는 실행 이력이며 공급자가 검증한 잔여량이 아닙니다.',
1518
+ 'States recorded through the availability command are execution history, not provider-verified remaining capacity.',
1519
+ ),
1520
+ ];
1521
+ const results = providers.map((agent) => {
1522
+ const providerEnabled = !!(agent && agent.envFlag && process.env[agent.envFlag] === '1');
1523
+ const base = _checkAgent(agent, {
1524
+ auth: providerEnabled,
1525
+ executeVersion: providerEnabled,
1526
+ cwd: root,
1527
+ });
1528
+ const source = userIds.has(agent.id) ? 'user' : 'builtin';
1529
+ const authCheckAttempted = !!base.authSource;
1530
+ if (authCheckAttempted) attemptedAuthChecks.push(agent.id);
1531
+ if (base.versionCheckAttempted) attemptedVersionChecks.push(agent.id);
1532
+ if (base.presenceCheckOnly) presenceOnlyChecks.push(agent.id);
1533
+ const providerIdentity = canonicalProviderIdentity(agent.id);
1534
+ const providerObservations = availabilityState.observations
1535
+ .filter(observation => canonicalProviderIdentity(observation.provider) === providerIdentity)
1536
+ .slice()
1537
+ .sort((a, b) => Number(a.ledgerOrdinal || 0) - Number(b.ledgerOrdinal || 0));
1538
+ const availabilityObservations = providerObservations
1539
+ .map(observation => ({
1540
+ model: observation.model || null,
1541
+ quota: observation.quota || null,
1542
+ rateLimited: observation.rateLimited == null ? null : observation.rateLimited === true,
1543
+ retryAfter: observation.retryAfter || null,
1544
+ reason: observation.reason || null,
1545
+ observedAt: observation.observedAt || observation.at || null,
1546
+ expiresAt: observation.expiresAt || null,
1547
+ }));
1548
+ // Summaries must use the same "newest applicable axis wins" semantics as
1549
+ // routing. Keeping provider-wide and model scopes independent leaves an
1550
+ // older model denial visible after a newer provider-wide recovery.
1551
+ const observedScopes = [...new Set(providerObservations.map(observation => observation.model || null))];
1552
+ const effectiveScopes = observedScopes.map(model => {
1553
+ const effective = availabilityExtrasForCandidate(availabilityState, { provider: agent.id, model });
1554
+ return {
1555
+ model,
1556
+ quota: effective.quota == null ? null : effective.quota,
1557
+ rateLimited: effective.rateLimited == null ? null : effective.rateLimited === true,
1558
+ };
1559
+ });
1560
+ const observedQuotaStates = new Set(effectiveScopes.map(observation => observation.quota).filter(Boolean));
1561
+ const quotaState = observedQuotaStates.size > 1
1562
+ ? 'observed-mixed'
1563
+ : observedQuotaStates.has('exhausted')
1564
+ ? 'observed-exhausted'
1565
+ : observedQuotaStates.has('available')
1566
+ ? 'observed-available'
1567
+ : 'not-observed';
1568
+
1569
+ const effectiveAvailability = normalizeAvailability(
1570
+ agent,
1571
+ base,
1572
+ availabilityExtrasForCandidate(availabilityState, { provider: agent.id, model: null }),
1573
+ );
1574
+ let routingEligibility;
1575
+ let routingReason;
1576
+ let hintCode;
1577
+ if (!effectiveAvailability.installed) {
1578
+ routingEligibility = 'blocked';
1579
+ routingReason = 'cli_unavailable';
1580
+ hintCode = 'install_cli';
1581
+ } else if (!effectiveAvailability.enabled) {
1582
+ routingEligibility = 'blocked';
1583
+ routingReason = 'provider_disabled';
1584
+ hintCode = 'enable_provider';
1585
+ } else if (!effectiveAvailability.eligible) {
1586
+ routingEligibility = 'blocked';
1587
+ routingReason = (effectiveAvailability.blockingReasons[0] || 'availability_blocked').replace(/-/g, '_');
1588
+ hintCode = routingReason === 'not_authenticated' ? 'restore_authentication' : 'restore_provider_availability';
1589
+ } else if (effectiveAvailability.authenticated === 'ok') {
1590
+ routingEligibility = 'eligible';
1591
+ routingReason = 'local_prerequisites_met';
1592
+ hintCode = 'authentication_only';
1593
+ } else {
1594
+ routingEligibility = 'unverified';
1595
+ routingReason = 'authentication_not_observed';
1596
+ hintCode = 'official_adapter_unavailable';
559
1597
  }
560
- results.push(out);
1598
+
1599
+ return {
1600
+ id: agent.id,
1601
+ source,
1602
+ bin: agent.bin,
1603
+ status: base.status,
1604
+ installed: !!base.installed,
1605
+ enabled: !!base.enabled,
1606
+ version: base.version || null,
1607
+ versionCheckAttempted: !!base.versionCheckAttempted,
1608
+ versionCheckState: base.versionCheckAttempted
1609
+ ? (base.versionCheckSucceeded ? 'ok' : 'failed')
1610
+ : 'not-attempted',
1611
+ presenceCheckOnly: !!base.presenceCheckOnly,
1612
+ auth: base.auth || 'unknown',
1613
+ authCheckAttempted,
1614
+ authEvidencePresent: !!base.authEvidence,
1615
+ authSource: base.authSource || null,
1616
+ routingEligibility,
1617
+ routingReason,
1618
+ modelCallability: 'not-observed',
1619
+ // Legacy field retained for callers that already read `callability`.
1620
+ callability: 'unknown',
1621
+ callabilityReason: 'live_model_call_not_performed',
1622
+ // Legacy `quota` keeps its string status for existing consumers. Numeric
1623
+ // capacity fields remain null until a verified official adapter exists.
1624
+ quota: quotaState === 'not-observed' ? 'unknown' : quotaState,
1625
+ quotaState,
1626
+ verifiedRemainingAmount: null,
1627
+ remaining: null,
1628
+ unit: null,
1629
+ resetAt: null,
1630
+ quotaSource: null,
1631
+ availabilityObservations,
1632
+ // Legacy alias retained for existing CLI/MCP consumers.
1633
+ observed: availabilityObservations,
1634
+ // Legacy field retained for callers that already display `hint`. Canonical guidance uses hintCode.
1635
+ hint: base.status === 'not-installed' ? `${agent.bin} CLI 미설치`
1636
+ : base.status === 'disabled' ? `${agent.envFlag}=1 필요`
1637
+ : hintCode,
1638
+ hintCode,
1639
+ raw: null,
1640
+ };
1641
+ });
1642
+ const policy = {
1643
+ projectEnvironmentMayBeLoaded: true,
1644
+ credentialValuesIncludedInOutput: false,
1645
+ credentialValuesPersistedByCommand: false,
1646
+ credentialValuesInspectedForCapacity: false,
1647
+ credentialValuesPassedToProbeCommands: false,
1648
+ providerCredentialStoresReadDirectly: false,
1649
+ providerCliMayReadOwnCredentialStore: attemptedAuthChecks.length > 0 || attemptedVersionChecks.length > 0,
1650
+ providerCliMayReadEnvironmentCredentials: false,
1651
+ disabledProviderCommandsExecuted: false,
1652
+ registeredSafeAuthChecksAttempted: attemptedAuthChecks,
1653
+ versionChecksAttempted: attemptedVersionChecks,
1654
+ presenceOnlyChecks,
1655
+ browserSessionReuse: false,
1656
+ guiScraping: false,
1657
+ liveModelCalls: false,
1658
+ capacityValuesRequireOfficialContract: true,
1659
+ recordedAvailabilityTreatedAsOfficialCapacity: false,
1660
+ speculativeCapacityClaims: false,
1661
+ };
1662
+ if (has('--json')) {
1663
+ const note = '설치·활성·일부 인증은 확인할 수 있지만, 모델 권한·남은 토큰/크레딧·과금 권한은 공통적으로 검증하지 않습니다. observed-*는 provider 수치 조회가 아니라 append-only 관측 기록입니다.';
1664
+ log(JSON.stringify({
1665
+ schemaVersion: 2,
1666
+ observation: 'provider-capacity',
1667
+ quota: results,
1668
+ availabilityObservationState: {
1669
+ activeCount: availabilityState.activeCount,
1670
+ partial: availabilityState.partial,
1671
+ truncated: availabilityState.truncated,
1672
+ },
1673
+ policy,
1674
+ limitations: limitationCodes,
1675
+ note,
1676
+ }, null, 2));
1677
+ return;
561
1678
  }
562
- if (has('--json')) { log(JSON.stringify({ quota: results }, null, 2)); return; }
563
- log(`# 외부 AI CLI quota 추정 (1.9.31)`);
1679
+ log(t(`# 외부 AI 공급자 상태 관측 (Leerness v${VERSION})`, `# External AI provider status observation (Leerness v${VERSION})`));
564
1680
  log('');
565
- log(`| Agent | 상태 | quota | 안내 |`);
566
- log(`|---|---|---|---|`);
1681
+ log(t(
1682
+ '| Provider | 출처 | 설치/활성 | 인증 | 로컬 라우팅 | 모델 호출 | 잔여량 |',
1683
+ '| Provider | source | installed/enabled | auth | local routing | model call | remaining |',
1684
+ ));
1685
+ log('|---|---|---|---|---|---|---|');
567
1686
  for (const q of results) {
568
- const statusEmoji = q.status === 'ready' ? '🟢' : q.status === 'not-installed' ? '⚪' : q.status === 'disabled' ? '🟡' : '❓';
569
- log(`| ${q.id} | ${statusEmoji} ${q.status} | ${q.quota || '-'} | ${q.hint || '-'} |`);
1687
+ const installation = `${q.installed ? 'yes' : 'no'}/${q.enabled ? 'yes' : 'no'}`;
1688
+ log(`| ${q.id} | ${q.source} | ${installation} | ${q.auth} | ${q.routingEligibility} | ${q.modelCallability} | ${q.quotaState} |`);
570
1689
  }
571
1690
  log('');
572
- log(`## 주의`);
573
- log(` - leerness는 CLI 사용량을 직접 추적하지 않음 (provider 대시보드 참조)`);
574
- log(` - rate-limit/quota는 plan/티어에 따라 달라짐`);
575
- log(` - sub-agent 분배 시 quota 여유 큰 CLI 우선 활용 권장`);
1691
+ log(t('## 관측 경계', '## Observation boundary'));
1692
+ for (const line of humanLimitations) log(` - ${line}`);
1693
+ log(t(
1694
+ ' - 프로젝트 .env는 활성화 설정을 적용하기 위해 로드될 수 있지만, 자격증명 값은 출력·저장하거나 잔여량 계산에 사용하지 않습니다.',
1695
+ ' - The project .env may be loaded to apply enablement settings, but credential values are not emitted, persisted, or used to calculate capacity.',
1696
+ ));
1697
+ log(t(
1698
+ attemptedAuthChecks.length
1699
+ ? ` - 등록된 안전한 인증 확인을 실행한 공급자 CLI(${attemptedAuthChecks.join(', ')})는 자체 자격증명 저장소를 읽을 수 있습니다. Leerness는 해당 저장소를 직접 읽지 않습니다.`
1700
+ : ' - 이번 실행에서는 공급자 인증 확인 명령을 실행하지 않았습니다.',
1701
+ attemptedAuthChecks.length
1702
+ ? ` - Provider CLIs used for registered safe authentication checks (${attemptedAuthChecks.join(', ')}) may read their own credential stores. Leerness does not read those stores directly.`
1703
+ : ' - No provider authentication check command was executed in this run.',
1704
+ ));
1705
+ log(t(
1706
+ ' - 브라우저 세션·GUI를 읽지 않았고 실제 모델 호출도 하지 않았습니다.',
1707
+ ' - Browser sessions and GUIs were not read, and no live model call was made.',
1708
+ ));
1709
+ log(t(
1710
+ ' - 비활성 공급자는 실행하지 않고 PATH 상 존재 여부만 확인하며, 모든 공급자 프로브에서 프로젝트 자격증명 환경변수를 제거합니다.',
1711
+ ' - Disabled providers are not executed; only PATH presence is checked, and project credential environment variables are removed from every provider probe.',
1712
+ ));
576
1713
  return;
577
1714
  }
578
-
579
- fail('사용법: leerness agents list|check|quota|dispatch|multi|bench|recommend [--write] "<task>" [--to <id>]');
1715
+ fail('사용법: leerness agents list|check|quota|availability|resolve|fallback|record|history|dispatch|multi|bench|recommend [--write] "<task>" [--to <id>]');
580
1716
  return process.exit(1);
581
1717
  }
582
1718
 
583
- module.exports = { agentsCmd, _benchLaunchSpec };
1719
+ module.exports = { agentsCmd, _benchLaunchSpec, _parseAgentPositional, _findAgentAction };