@ljwei-stak/dsh-model-router 0.21.1 → 0.22.1
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/.dsh-plugin/client.js +872 -80
- package/.dsh-plugin/index.mjs +225 -25
- package/.dsh-plugin/shared/assignment-solver.mjs +235 -35
- package/.dsh-plugin/shared/harness-plan.mjs +16 -4
- package/.dsh-plugin/shared/manual-plan.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +8 -1
- package/.dsh-plugin/shared/official-tools-remote.mjs +10 -0
- package/.dsh-plugin/shared/package-assignments.mjs +71 -0
- package/.dsh-plugin/shared/quality-gates.mjs +169 -0
- package/.dsh-plugin/shared/reasoning-execution.mjs +92 -0
- package/.dsh-plugin/shared/router-state.mjs +2 -1
- package/.dsh-plugin/shared/router.mjs +124 -12
- package/.dsh-plugin/shared/routing-evidence.mjs +720 -0
- package/.dsh-plugin/shared/run-ledger.mjs +85 -3
- package/.dsh-plugin/shared/task-executors.mjs +182 -27
- package/CHANGELOG.md +20 -0
- package/README.i18n.yaml +2 -2
- package/README.md +46 -25
- package/README.zh.md +46 -25
- package/docs/INSTALLATION_GUIDE.zh.md +12 -12
- package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +46 -0
- package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +91 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +2 -2
- package/docs/releases/0.22.0-validation.md +28 -0
- package/package.json +16 -4
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
* gentle routing adjustments from user ratings and reviews.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { reasoningExecutionFields, unsupportedCliReasoning } from './reasoning-execution.mjs'
|
|
8
|
+
import { packageSelectionFields } from './package-assignments.mjs'
|
|
9
|
+
|
|
7
10
|
export const MAX_STORED_ANSWER = 4_000
|
|
8
11
|
export const MAX_STORED_TASK = 20_000
|
|
9
12
|
/** Ratings move a route's quality by at most this much (quality is 0–1). */
|
|
10
13
|
export const MAX_QUALITY_BIAS = 0.04
|
|
11
14
|
|
|
12
15
|
const finite = value => typeof value === 'number' && Number.isFinite(value)
|
|
16
|
+
const nonEmptyText = value => (typeof value === 'string' && value.length > 0 ? value : null)
|
|
13
17
|
const routeKey = (provider, model) => `${String(provider ?? '')}\u0000${String(model ?? '')}`
|
|
14
18
|
|
|
15
19
|
function modelIdentity(value) {
|
|
@@ -20,8 +24,48 @@ function modelIdentity(value) {
|
|
|
20
24
|
return null
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
// A stop marker belongs to one attempt, not to earlier requests in a cascade.
|
|
28
|
+
// Contradictory positive/invalid counters remain potentially paid evidence.
|
|
29
|
+
function clearlyNotStarted(attempt) {
|
|
30
|
+
if (!attempt || typeof attempt !== 'object' || (attempt.notStarted !== true && attempt.blocked !== true)) return false
|
|
31
|
+
if (Object.hasOwn(attempt, 'reportedCostUsd') && attempt.reportedCostUsd !== undefined
|
|
32
|
+
&& attempt.reportedCostUsd !== null && attempt.reportedCostUsd !== 0) return false
|
|
33
|
+
return !['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some(key => {
|
|
34
|
+
const value = attempt.usage?.[key]
|
|
35
|
+
return value !== undefined && value !== null && value !== 0
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when some quality attempt ran, or execution cannot safely be ruled out. */
|
|
40
|
+
export function hasStartedQualityAttempts(result) {
|
|
41
|
+
if (!result || !Object.hasOwn(result, 'qualityAttempts')) return false
|
|
42
|
+
const attempts = result.qualityAttempts
|
|
43
|
+
if (!Array.isArray(attempts) || attempts.length < 1 || attempts.length > 2) return true
|
|
44
|
+
return attempts.some(attempt => !clearlyNotStarted(attempt))
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
/** USD cost of one package. Prefers a CLI-reported cost, else usage × configured price. */
|
|
24
48
|
export function actualCost(result, pricing) {
|
|
49
|
+
if (result && Object.hasOwn(result, 'qualityAttempts')) {
|
|
50
|
+
const attempts = result.qualityAttempts
|
|
51
|
+
if (!Array.isArray(attempts) || attempts.length < 1 || attempts.length > 2
|
|
52
|
+
|| attempts.some(item => !item || typeof item !== 'object' || Object.hasOwn(item, 'qualityAttempts')
|
|
53
|
+
|| item.channel === 'official-cli' || item.billing === 'subscription')) {
|
|
54
|
+
return { costUsd: null, costSource: 'quality-attempts-invalid' }
|
|
55
|
+
}
|
|
56
|
+
const costs = attempts.map(item => clearlyNotStarted(item)
|
|
57
|
+
? { costUsd: 0, costSource: 'not-run' }
|
|
58
|
+
: actualCost(item, item.pricingSnapshot ?? null))
|
|
59
|
+
if (costs.some(item => item.costUsd === null)) {
|
|
60
|
+
const knownCostUsd = costs.filter(item => item.costUsd !== null).reduce((sum, item) => sum + item.costUsd, 0)
|
|
61
|
+
return finite(knownCostUsd) ? { costUsd: null, costSource: 'quality-attempts-incomplete', knownCostUsd,
|
|
62
|
+
unknownAttemptCount: costs.filter(item => item.costUsd === null).length }
|
|
63
|
+
: { costUsd: null, costSource: 'cost-invalid' }
|
|
64
|
+
}
|
|
65
|
+
const total = costs.reduce((sum, item) => sum + item.costUsd, 0)
|
|
66
|
+
return finite(total) && total >= 0 ? { costUsd: total, costSource: 'quality-attempts' }
|
|
67
|
+
: { costUsd: null, costSource: 'cost-invalid' }
|
|
68
|
+
}
|
|
25
69
|
if (result && Object.hasOwn(result, 'reportedCostUsd') && finite(result.reportedCostUsd) && result.reportedCostUsd >= 0) {
|
|
26
70
|
return { costUsd: result.reportedCostUsd, costSource: 'cli-reported' }
|
|
27
71
|
}
|
|
@@ -148,7 +192,10 @@ function* spendingEntries(runs) {
|
|
|
148
192
|
amount.subscriptionRuns = 1
|
|
149
193
|
if (finite(item.referenceCostUsd) && item.referenceCostUsd >= 0) amount.subscriptionUsd = item.referenceCostUsd
|
|
150
194
|
} else if (finite(item.costUsd) && item.costUsd >= 0) amount.costUsd = item.costUsd
|
|
151
|
-
else if (item.ran === true)
|
|
195
|
+
else if (item.ran === true) {
|
|
196
|
+
amount.unknown = 1
|
|
197
|
+
if (finite(item.knownCostUsd) && item.knownCostUsd >= 0) amount.costUsd = item.knownCostUsd
|
|
198
|
+
}
|
|
152
199
|
else continue
|
|
153
200
|
yield { when, amount }
|
|
154
201
|
}
|
|
@@ -257,11 +304,16 @@ export function applyQualityBiases(routes, biases) {
|
|
|
257
304
|
}
|
|
258
305
|
|
|
259
306
|
function storedPackage(planned, result, pricing, finishedAt, billingFor = billingOf) {
|
|
260
|
-
const
|
|
307
|
+
const plannedEffort = nonEmptyText(planned?.recommendedReasoningEffort) ?? nonEmptyText(planned?.plannedReasoningEffort)
|
|
308
|
+
const startedAttempts = hasStartedQualityAttempts(result)
|
|
309
|
+
const qualityAttempts = Array.isArray(result?.qualityAttempts)
|
|
310
|
+
? result.qualityAttempts.slice(0, 2).map(attempt => attempt && typeof attempt === 'object' ? attempt : {}) : null
|
|
311
|
+
const cost = !result || ((result.blocked || result.notStarted) && !startedAttempts)
|
|
261
312
|
? { costUsd: null, costSource: 'not-run', billing: null, referenceCostUsd: null }
|
|
262
313
|
: billedCost(result, pricing, billingFor(result))
|
|
263
314
|
return {
|
|
264
315
|
id: planned.id,
|
|
316
|
+
...packageSelectionFields(result?.manualAssignment !== undefined ? { ...planned, ...result } : planned),
|
|
265
317
|
name: planned.name,
|
|
266
318
|
objective: String(planned.objective ?? '').slice(0, 2_000),
|
|
267
319
|
...(planned.type ? { type: String(planned.type) } : {}),
|
|
@@ -270,6 +322,24 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
|
|
|
270
322
|
dependsOn: [...(planned.dependsOn ?? [])],
|
|
271
323
|
recommendedProvider: planned.recommendedProvider,
|
|
272
324
|
recommendedModel: planned.recommendedModel,
|
|
325
|
+
...reasoningExecutionFields(result),
|
|
326
|
+
...(qualityAttempts ? { qualityAttempts: qualityAttempts.map(attempt => ({
|
|
327
|
+
provider: attempt.provider, model: attempt.model, channel: 'harness-llm',
|
|
328
|
+
ok: attempt.ok === true, usage: attempt.usage ?? null,
|
|
329
|
+
...(attempt.notStarted === true ? { notStarted: true } : {}),
|
|
330
|
+
...(attempt.blocked === true ? { blocked: true } : {}),
|
|
331
|
+
...(attempt.usageIncomplete ? { usageIncomplete: true } : {}),
|
|
332
|
+
...(finite(attempt.reportedCostUsd) && attempt.reportedCostUsd >= 0 ? { reportedCostUsd: attempt.reportedCostUsd } : {}),
|
|
333
|
+
pricingSnapshot: attempt.pricingSnapshot ? { ...attempt.pricingSnapshot } : null,
|
|
334
|
+
...reasoningExecutionFields(attempt),
|
|
335
|
+
...(finite(attempt.durationMs) ? { durationMs: attempt.durationMs } : {}),
|
|
336
|
+
...(finite(attempt.inputChars) ? { inputChars: attempt.inputChars } : {}),
|
|
337
|
+
finishedAt: attempt.finishedAt, error: typeof attempt.error === 'string' ? attempt.error.slice(0, 1_000) : null,
|
|
338
|
+
})) } : {}),
|
|
339
|
+
...(result?.qualityValidation ? { qualityValidation: { ...result.qualityValidation } } : {}),
|
|
340
|
+
...(plannedEffort ? { plannedReasoningEffort: plannedEffort } : {}),
|
|
341
|
+
...(finite(result?.durationMs) && result.durationMs >= 0 ? { durationMs: result.durationMs } : {}),
|
|
342
|
+
...(finite(result?.inputChars) && result.inputChars >= 0 ? { inputChars: result.inputChars } : {}),
|
|
273
343
|
difficulty: planned.difficulty ?? null,
|
|
274
344
|
estimatedCost: finite(planned.estimatedCost) ? planned.estimatedCost : null,
|
|
275
345
|
plannedChannel: planned.executionChannel ?? null,
|
|
@@ -278,7 +348,7 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
|
|
|
278
348
|
status: !result ? 'pending' : result.ok ? (result.fallback ? 'fallback' : 'succeeded') : result.paused ? 'paused'
|
|
279
349
|
: result.waiting ? 'waiting' : result.blocked ? 'blocked' : result.cancelled ? 'cancelled' : 'failed',
|
|
280
350
|
ok: result?.ok === true,
|
|
281
|
-
ran: Boolean(result) && !result.blocked && !result.notStarted,
|
|
351
|
+
ran: Boolean(result) && (startedAttempts || (!result.blocked && !result.notStarted)),
|
|
282
352
|
blocked: result?.blocked === true,
|
|
283
353
|
...(result?.paused ? { paused: true, pause: { ...result.pause } } : {}),
|
|
284
354
|
...(result?.waiting ? { waiting: true } : {}),
|
|
@@ -324,6 +394,7 @@ export function plannedPackages(plan, task) {
|
|
|
324
394
|
const route = plan.directRoute ?? plan.selected
|
|
325
395
|
return [{ id: 'direct', name: '指定模型', objective: String(task ?? ''), dependsOn: [],
|
|
326
396
|
recommendedProvider: route?.provider, recommendedModel: route?.model,
|
|
397
|
+
...(typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? { recommendedReasoningEffort: route.reasoningEffort } : {}),
|
|
327
398
|
difficulty: plan.complexity?.band ?? null, estimatedCost: plan.estimatedCost ?? null,
|
|
328
399
|
executionChannel: plan.executionChannel ?? null }]
|
|
329
400
|
}
|
|
@@ -344,6 +415,9 @@ export function buildRunRecord({ id, createdAt, task, plan, execution, preset =
|
|
|
344
415
|
executionMode,
|
|
345
416
|
routingBypassed: plan?.routingBypassed === true,
|
|
346
417
|
mode: plan?.mode ?? 'single',
|
|
418
|
+
...(typeof plan?.assignmentMode === 'string' && plan.assignmentMode.trim().length > 0
|
|
419
|
+
&& plan.assignmentMode.length <= 40 && !/[\u0000-\u001f\u007f]/u.test(plan.assignmentMode)
|
|
420
|
+
? { assignmentMode: plan.assignmentMode.trim() } : {}),
|
|
347
421
|
preset,
|
|
348
422
|
decision: {
|
|
349
423
|
dataVersions: plan?.optimization?.dataVersions ?? null,
|
|
@@ -385,6 +459,10 @@ export function storedResults(run) {
|
|
|
385
459
|
id: item.id, name: item.name, ok: item.ok, provider: item.provider, model: item.model,
|
|
386
460
|
channel: item.channel, answer: item.answer, error: item.error, fallback: item.fallback,
|
|
387
461
|
blocked: item.blocked, finishedAt: item.finishedAt,
|
|
462
|
+
...packageSelectionFields(item),
|
|
463
|
+
...reasoningExecutionFields(item),
|
|
464
|
+
...(finite(item.durationMs) && item.durationMs >= 0 ? { durationMs: item.durationMs } : {}),
|
|
465
|
+
...(finite(item.inputChars) && item.inputChars >= 0 ? { inputChars: item.inputChars } : {}),
|
|
388
466
|
...(item.paused ? { paused: true } : {}), ...(item.waiting ? { waiting: true } : {}),
|
|
389
467
|
}))
|
|
390
468
|
}
|
|
@@ -406,6 +484,10 @@ export function teamExecutionResults(plan, execution) {
|
|
|
406
484
|
cancelled: result.status === 'cancelled',
|
|
407
485
|
provider: result.provider,
|
|
408
486
|
model: result.recommendedModel,
|
|
487
|
+
...unsupportedCliReasoning({ reasoningEffort: result.recommendedReasoningEffort }),
|
|
488
|
+
...reasoningExecutionFields(result),
|
|
489
|
+
...(finite(result.durationMs) && result.durationMs >= 0 ? { durationMs: result.durationMs } : {}),
|
|
490
|
+
...(finite(result.inputChars) && result.inputChars >= 0 ? { inputChars: result.inputChars } : {}),
|
|
409
491
|
requestedCliModel: result.requestedCliModel ?? null,
|
|
410
492
|
actualModel: result.actualModel ?? null,
|
|
411
493
|
...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
|
|
@@ -21,6 +21,8 @@ import { installedModuleRoots } from './vendor-mimo-grok-adapter.mjs'
|
|
|
21
21
|
import { CODE_FILE, npmAttestor } from './npm-attestation.mjs'
|
|
22
22
|
import { normalizeExecutionPreference } from './model-profiles.mjs'
|
|
23
23
|
import { BILLING_MODE_LABEL, billingPlan, detectQuotaExhaustion, vendorKey } from './subscription-billing.mjs'
|
|
24
|
+
import { reasoningExecutionFields, reasoningRouteForAssignment, routeForReasoningSwitch, unsupportedCliReasoning } from './reasoning-execution.mjs'
|
|
25
|
+
import { parsePackageAssignments, packageSelectionFields } from './package-assignments.mjs'
|
|
24
26
|
|
|
25
27
|
export const MAX_TASK_BYTES = 64_000
|
|
26
28
|
const MAX_ANSWER_CHARS = 48_000
|
|
@@ -42,6 +44,17 @@ const BASE_ENV_KEYS = Object.freeze([
|
|
|
42
44
|
|
|
43
45
|
const READ_ONLY_INSTRUCTION = 'Read the task supplied on standard input. Answer in the task language. Do not modify files.'
|
|
44
46
|
|
|
47
|
+
function additionalApiFields(value) {
|
|
48
|
+
if (!value || typeof value !== 'object') return {}
|
|
49
|
+
return {
|
|
50
|
+
...(value.notStarted === true ? { notStarted: true } : {}),
|
|
51
|
+
...(value.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
52
|
+
...(value.qualityValidation ? { qualityValidation: value.qualityValidation } : {}),
|
|
53
|
+
...(Array.isArray(value.qualityAttempts) ? { qualityAttempts: value.qualityAttempts,
|
|
54
|
+
...(typeof value.provider === 'string' && typeof value.model === 'string' ? { provider: value.provider, model: value.model } : {}) } : {}),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/** Portable headless adapters. Registry tools without an entry here use the verified runner. */
|
|
46
59
|
export const TASK_ADAPTERS = Object.freeze([
|
|
47
60
|
Object.freeze({
|
|
@@ -167,8 +180,13 @@ const TRUNCATED = '…(已截断)'
|
|
|
167
180
|
|
|
168
181
|
function truncatedTo(value, maxBytes) {
|
|
169
182
|
const textValue = String(value ?? '')
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
const limit = Math.max(0, Math.floor(maxBytes) || 0)
|
|
184
|
+
if (Buffer.byteLength(textValue, 'utf8') <= limit) return textValue
|
|
185
|
+
const markerBytes = Buffer.byteLength(TRUNCATED, 'utf8')
|
|
186
|
+
// A marker must not consume more than the whole allocation. Very small
|
|
187
|
+
// shares keep useful content rather than an oversized or partial marker.
|
|
188
|
+
if (limit < markerBytes) return sliceUtf8(textValue, limit)
|
|
189
|
+
return `${sliceUtf8(textValue, limit - markerBytes)}${TRUNCATED}`
|
|
172
190
|
}
|
|
173
191
|
|
|
174
192
|
function checkedModel(modelId) {
|
|
@@ -626,6 +644,8 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
|
|
|
626
644
|
ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter?.id ?? null, answer: '',
|
|
627
645
|
fallback: null, timedOut, exitCode, paused: true,
|
|
628
646
|
pause: { ...api.pause, ...(detail ? { detail } : {}), ...(fallback.loginRequired ? { loginRequired: true } : {}) },
|
|
647
|
+
...reasoningExecutionFields(api),
|
|
648
|
+
...additionalApiFields(api),
|
|
629
649
|
error: api.error,
|
|
630
650
|
}
|
|
631
651
|
}
|
|
@@ -635,6 +655,8 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
|
|
|
635
655
|
ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
|
|
636
656
|
answer: '', fallback, timedOut, exitCode, error: '执行已取消。', cancelled: true,
|
|
637
657
|
...(api?.usage ? { usage: api.usage } : {}),
|
|
658
|
+
...reasoningExecutionFields(api),
|
|
659
|
+
...additionalApiFields(api),
|
|
638
660
|
}
|
|
639
661
|
}
|
|
640
662
|
// A billing-aware fallback can refuse (subscription-only) or explain a switch.
|
|
@@ -644,6 +666,9 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
|
|
|
644
666
|
provider, model, preference, channel: api?.refused ? 'official-cli' : 'harness-llm', toolId: adapter?.id ?? null,
|
|
645
667
|
answer, fallback: note?.reason ? { ...fallback, reason: note.reason } : api?.refused ? { ...fallback, reason: api.error } : fallback, timedOut, exitCode,
|
|
646
668
|
...(api?.usage ? { usage: api.usage } : {}),
|
|
669
|
+
...reasoningExecutionFields(api),
|
|
670
|
+
...additionalApiFields(api),
|
|
671
|
+
...(api?.notStarted === true ? { notStarted: true } : {}),
|
|
647
672
|
...(note ? { billingSwitch: note } : {}),
|
|
648
673
|
...(api?.billing ? { billing: api.billing } : {}),
|
|
649
674
|
error: api?.ok === true && answer.trim() ? undefined : (api?.error || reason),
|
|
@@ -676,6 +701,7 @@ function officialSuccess({ route, adapter, preference, answer, exitCode, version
|
|
|
676
701
|
requestedCliModel = null, reportedModel = null, modelNotice = null, source, sandboxEnforcement }) {
|
|
677
702
|
const actualModel = reportedCliModel(reportedModel)
|
|
678
703
|
return {
|
|
704
|
+
...unsupportedCliReasoning(route),
|
|
679
705
|
ok: true,
|
|
680
706
|
provider: route.provider,
|
|
681
707
|
model: route.model,
|
|
@@ -952,10 +978,15 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
|
|
|
952
978
|
if (!apiRoute || (sub?.kind === 'plan-key' && apiRoute.provider === sub.route.provider && apiRoute.model === sub.route.model)) {
|
|
953
979
|
return refuse(`${reason.replace(/,?已切换 API Key。$/u, '。')}没有配置可回退的 API 路线(apiRoute)。`, note ? { billingSwitch: { ...note, to: null } } : {})
|
|
954
980
|
}
|
|
955
|
-
const
|
|
981
|
+
const reasoningRoute = routeForReasoningSwitch(route, apiRoute)
|
|
982
|
+
const originalEffort = typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? route.reasoningEffort : null
|
|
983
|
+
const result = await useApiRoute(apiFallback, { route: reasoningRoute, task: options.task, signal: options.signal, reason, skipped })
|
|
984
|
+
const droppedEffort = originalEffort && reasoningRoute.reasoningEffort !== originalEffort ? originalEffort : null
|
|
956
985
|
// Keep the API route that actually ran (pricing); the plan route is recorded separately.
|
|
957
986
|
return {
|
|
958
|
-
...base, ...result,
|
|
987
|
+
...base, ...result,
|
|
988
|
+
...(droppedEffort ? { plannedReasoningEffort: droppedEffort, reasoningEffortStatus: 'unapplied' } : {}),
|
|
989
|
+
billingMode: plan.mode, billing: 'api',
|
|
959
990
|
...(note ? { billingSwitch: { ...note, to: 'api' } } : {}),
|
|
960
991
|
...(sub?.kind === 'plan-key' ? { subscriptionRoute: { ...sub.route } } : {}),
|
|
961
992
|
}
|
|
@@ -985,13 +1016,19 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
|
|
|
985
1016
|
}
|
|
986
1017
|
|
|
987
1018
|
if (sub.kind === 'plan-key') {
|
|
1019
|
+
const reasoningRoute = routeForReasoningSwitch(route, sub.route)
|
|
1020
|
+
const originalEffort = typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? route.reasoningEffort : null
|
|
988
1021
|
let attempt
|
|
989
|
-
try { attempt = await apiFallback({ route:
|
|
1022
|
+
try { attempt = await apiFallback({ route: reasoningRoute, task: options.task, signal: options.signal, reason: '编程套餐(订阅)', subscription: true }) }
|
|
990
1023
|
catch (error) { attempt = { ok: false, error: String(error?.message ?? error) } }
|
|
1024
|
+
const droppedEffort = originalEffort && reasoningRoute.reasoningEffort !== originalEffort ? originalEffort : null
|
|
991
1025
|
if (attempt?.ok === true && String(attempt.answer ?? '').trim()) {
|
|
992
1026
|
return {
|
|
993
1027
|
ok: true, ...base, preference: executionPreference(route), channel: 'harness-llm', toolId: null,
|
|
994
1028
|
answer: String(attempt.answer).slice(0, MAX_ANSWER_CHARS), fallback: null,
|
|
1029
|
+
...reasoningExecutionFields(attempt),
|
|
1030
|
+
...additionalApiFields(attempt),
|
|
1031
|
+
...(droppedEffort ? { plannedReasoningEffort: droppedEffort, reasoningEffortStatus: 'unapplied' } : {}),
|
|
995
1032
|
billing: 'subscription', subscriptionRoute: { ...sub.route },
|
|
996
1033
|
...(attempt.usage ? { usage: attempt.usage } : {}),
|
|
997
1034
|
}
|
|
@@ -1058,8 +1095,13 @@ ${args?.raw ?? ''}`, vendor)
|
|
|
1058
1095
|
if (plan.mode === 'subscription-only') {
|
|
1059
1096
|
return { ok: false, refused: true, error: reason.replace('已切换 API Key。', '按设置只用订阅,未使用 API Key。'), billingNote: { ...note, to: null } }
|
|
1060
1097
|
}
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1098
|
+
const reasoningRoute = routeForReasoningSwitch(args.route, apiRoute ?? args.route)
|
|
1099
|
+
const api = await apiFallback({ ...args, route: reasoningRoute })
|
|
1100
|
+
const droppedEffort = typeof args.route?.reasoningEffort === 'string' && args.route.reasoningEffort
|
|
1101
|
+
&& reasoningRoute.reasoningEffort !== args.route.reasoningEffort ? args.route.reasoningEffort : null
|
|
1102
|
+
return { ...api,
|
|
1103
|
+
...(droppedEffort ? { plannedReasoningEffort: droppedEffort, reasoningEffortStatus: 'unapplied' } : {}),
|
|
1104
|
+
billing: 'api', billingNote: { ...note, to: 'api' } }
|
|
1063
1105
|
}
|
|
1064
1106
|
if (plan.mode === 'subscription-only') return { ok: false, refused: true, error: `${args?.reason ?? '官方 CLI 执行失败。'}(按设置只用订阅,未回退 API)` }
|
|
1065
1107
|
// The subscription really ran and failed for another reason: do not spend API money silently.
|
|
@@ -1100,7 +1142,7 @@ const DEPENDENCY_ANSWER_BYTES = 7_500
|
|
|
1100
1142
|
* The prompt for one work package, always within MAX_TASK_BYTES: the objective
|
|
1101
1143
|
* is capped first, dependency answers share what the task leaves (each at most
|
|
1102
1144
|
* DEPENDENCY_ANSWER_BYTES, the remainder split evenly), and the total task is
|
|
1103
|
-
* truncated last. Truncated parts
|
|
1145
|
+
* truncated last. Truncated parts include a marker when their share permits it.
|
|
1104
1146
|
*/
|
|
1105
1147
|
export function packagePrompt(task, item, completed, maxBytes = MAX_TASK_BYTES) {
|
|
1106
1148
|
const dependencies = (item.dependsOn ?? []).map(id => completed.find(result => result.id === id)).filter(Boolean)
|
|
@@ -1114,13 +1156,17 @@ export function packagePrompt(task, item, completed, maxBytes = MAX_TASK_BYTES)
|
|
|
1114
1156
|
const taskBytes = bytes(String(task))
|
|
1115
1157
|
const free = Math.max(0, budget - fixed)
|
|
1116
1158
|
// Dependencies get what they need up to their cap, but never squeeze the task below its share.
|
|
1117
|
-
const
|
|
1159
|
+
const wanted = dependencies.map(dep => Math.min(bytes(String(dep.answer ?? '')), DEPENDENCY_ANSWER_BYTES))
|
|
1160
|
+
const wantDeps = wanted.reduce((sum, value) => sum + value, 0)
|
|
1118
1161
|
const depBudget = Math.max(0, Math.min(wantDeps, free - Math.min(taskBytes, MIN_TASK_SHARE_BYTES)))
|
|
1119
1162
|
let remaining = depBudget
|
|
1163
|
+
let remainingWanted = wantDeps
|
|
1120
1164
|
const depTexts = dependencies.map((dep, index) => {
|
|
1121
|
-
const share =
|
|
1122
|
-
|
|
1165
|
+
const share = remaining >= remainingWanted ? wanted[index]
|
|
1166
|
+
: Math.min(wanted[index], Math.floor(remaining / (dependencies.length - index)))
|
|
1167
|
+
const answer = truncatedTo(String(dep.answer ?? ''), share)
|
|
1123
1168
|
remaining -= bytes(answer)
|
|
1169
|
+
remainingWanted -= wanted[index]
|
|
1124
1170
|
return `${truncatedTo(dep.name, 200)}:\n${answer}`
|
|
1125
1171
|
})
|
|
1126
1172
|
const taskText = truncatedTo(String(task), Math.max(0, free - (depBudget - remaining)))
|
|
@@ -1146,6 +1192,7 @@ export function assignmentPackages(plan, task) {
|
|
|
1146
1192
|
dependsOn: [],
|
|
1147
1193
|
recommendedProvider: route.provider,
|
|
1148
1194
|
recommendedModel: route.model,
|
|
1195
|
+
...(typeof route.reasoningEffort === 'string' && route.reasoningEffort ? { recommendedReasoningEffort: route.reasoningEffort } : {}),
|
|
1149
1196
|
}]
|
|
1150
1197
|
}
|
|
1151
1198
|
const packages = plan?.team?.workPackages
|
|
@@ -1157,6 +1204,10 @@ export function assignmentPackages(plan, task) {
|
|
|
1157
1204
|
dependsOn: [...(item.dependsOn ?? [])],
|
|
1158
1205
|
recommendedProvider: item.recommendedProvider,
|
|
1159
1206
|
recommendedModel: item.recommendedModel,
|
|
1207
|
+
...(item.type ? { type: item.type } : {}),
|
|
1208
|
+
...(item.purpose ? { purpose: item.purpose } : {}),
|
|
1209
|
+
...packageSelectionFields(item),
|
|
1210
|
+
...(typeof item.recommendedReasoningEffort === 'string' && item.recommendedReasoningEffort ? { recommendedReasoningEffort: item.recommendedReasoningEffort } : {}),
|
|
1160
1211
|
}))
|
|
1161
1212
|
}
|
|
1162
1213
|
|
|
@@ -1172,6 +1223,75 @@ function aggregateOf(results) {
|
|
|
1172
1223
|
return { status, packages: results, aggregate }
|
|
1173
1224
|
}
|
|
1174
1225
|
|
|
1226
|
+
function checkedPackageSelections(packages, routes, overrides, runVerified, targets) {
|
|
1227
|
+
const preceding = new Set()
|
|
1228
|
+
const selected = {}
|
|
1229
|
+
const effective = packages.map(item => {
|
|
1230
|
+
if (typeof item?.id !== 'string' || item.id !== item.id.trim() || !item.id || item.id.length > 128
|
|
1231
|
+
|| /[\u0000-\u001f\u007f]/u.test(item.id) || ['__proto__', 'constructor', 'prototype'].includes(item.id)
|
|
1232
|
+
|| preceding.has(item.id) || !Array.isArray(item.dependsOn ?? [])
|
|
1233
|
+
|| (item.dependsOn ?? []).some(id => !preceding.has(id))) {
|
|
1234
|
+
throw new TypeError('Work packages require unique ids and topological dependencies')
|
|
1235
|
+
}
|
|
1236
|
+
preceding.add(item.id)
|
|
1237
|
+
const override = Object.hasOwn(overrides, item.id) ? overrides[item.id] : null
|
|
1238
|
+
const next = item.manualAssignment && override?.provider && override?.model
|
|
1239
|
+
? { ...item, manualAssignment: { kind: 'api', provider: override.provider, model: override.model } } : item
|
|
1240
|
+
if (next.manualAssignment !== undefined) selected[next.id] = next.manualAssignment
|
|
1241
|
+
return next
|
|
1242
|
+
})
|
|
1243
|
+
const normalized = Object.keys(selected).length ? parsePackageAssignments(selected) : undefined
|
|
1244
|
+
for (const [id, target] of Object.entries(normalized ?? {})) {
|
|
1245
|
+
// Retained successful receipts are context, not new execution requests.
|
|
1246
|
+
// Their exact intent still validates above; a removed catalog entry or
|
|
1247
|
+
// unavailable runner must not prevent a different target from retrying.
|
|
1248
|
+
if (targets && !targets.has(id)) continue
|
|
1249
|
+
if (target.kind === 'api' && !routes.some(route => route.provider === target.provider && route.model === target.model)) {
|
|
1250
|
+
throw new TypeError(`Manual API route is not configured for work package ${id}`)
|
|
1251
|
+
}
|
|
1252
|
+
if (target.kind === 'tool' && typeof runVerified !== 'function') {
|
|
1253
|
+
throw new TypeError('Manual official tool assignments require runVerified')
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return effective.map(item => normalized?.[item.id]
|
|
1257
|
+
? { ...item, ...packageSelectionFields({ ...item, manualAssignment: normalized[item.id] }) } : item)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async function runManualTool(target, prompt, options) {
|
|
1261
|
+
const base = { provider: 'official-tool', model: target.toolId, toolId: target.toolId,
|
|
1262
|
+
channel: 'official-cli', preference: 'official', requestedCliModel: null,
|
|
1263
|
+
modelNotice: '使用所选官方工具配置的默认模型。', fallback: null }
|
|
1264
|
+
try {
|
|
1265
|
+
const raw = await options.runVerified({ toolId: target.toolId, task: prompt, workspace: options.workspace,
|
|
1266
|
+
mode: 'read-only', signal: options.signal })
|
|
1267
|
+
if (!raw || typeof raw.status !== 'string') throw new TypeError('官方工具未返回有效执行状态。')
|
|
1268
|
+
const answer = typeof raw.finalText === 'string' ? raw.finalText : ''
|
|
1269
|
+
const ok = raw.status === 'succeeded' && answer.trim().length > 0
|
|
1270
|
+
const executionEvidence = Number.isFinite(raw.startedAt) || raw.usage != null
|
|
1271
|
+
|| (Number.isFinite(raw.reportedCostUsd) && raw.reportedCostUsd > 0)
|
|
1272
|
+
const notStarted = !executionEvidence && (raw.status === 'unsupported' || raw.notStarted === true || raw.blocked === true)
|
|
1273
|
+
return { ...base, ok, answer: answer.slice(0, MAX_ANSWER_CHARS),
|
|
1274
|
+
actualModel: reportedCliModel(raw.actualModel ?? raw.reportedModel),
|
|
1275
|
+
...(raw.reportedModel !== undefined ? { reportedModel: raw.reportedModel } : {}),
|
|
1276
|
+
...(raw.usage != null ? { usage: raw.usage } : {}),
|
|
1277
|
+
...(raw.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
1278
|
+
...(Number.isFinite(raw.reportedCostUsd) && raw.reportedCostUsd >= 0 ? { reportedCostUsd: raw.reportedCostUsd } : {}),
|
|
1279
|
+
...Object.fromEntries(['credentialSource', 'source', 'sandboxEnforcement', 'executionEnvironment']
|
|
1280
|
+
.filter(key => typeof raw[key] === 'string').map(key => [key, raw[key]])),
|
|
1281
|
+
...(Number.isInteger(raw.exitCode) ? { exitCode: raw.exitCode } : {}),
|
|
1282
|
+
...(notStarted ? { notStarted: true } : {}),
|
|
1283
|
+
...(raw.status === 'cancelled' || options.signal?.aborted ? { cancelled: true } : {}),
|
|
1284
|
+
...(raw.status === 'timed-out' ? { timedOut: true } : {}),
|
|
1285
|
+
...(raw.status === 'output-limit' || answer.length > MAX_ANSWER_CHARS ? { truncated: true } : {}),
|
|
1286
|
+
...(!ok && (raw.diagnostic || raw.outputTail) ? { diagnostic: redactDiagnostic(raw.diagnostic ?? raw.outputTail) } : {}),
|
|
1287
|
+
error: ok ? undefined : redactDiagnostic(raw.error ?? raw.reason ?? (answer.trim() ? '官方工具执行失败。' : '官方工具未返回有效文本。')) }
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
return { ...base, ok: false, answer: '', actualModel: null, usageIncomplete: true,
|
|
1290
|
+
...(options.signal?.aborted || error?.name === 'AbortError' ? { cancelled: true } : {}),
|
|
1291
|
+
error: redactDiagnostic(error?.message ?? error) }
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1175
1295
|
/**
|
|
1176
1296
|
* Run `targets` (all packages when null) in plan order. Packages outside the
|
|
1177
1297
|
* target set keep their previous results, so a single failed step can be
|
|
@@ -1180,20 +1300,26 @@ function aggregateOf(results) {
|
|
|
1180
1300
|
async function runPackages({ packages, task, routingBypassed, routes, previous = [], targets = null, overrides = {}, choices = {}, options }) {
|
|
1181
1301
|
const routeList = Array.isArray(routes) ? routes : []
|
|
1182
1302
|
const { credentialsFor, onPackage, billing, ...runOptions } = options
|
|
1303
|
+
const effectivePackages = checkedPackageSelections(packages, routeList, overrides, runOptions.runVerified, targets)
|
|
1183
1304
|
const results = []
|
|
1184
1305
|
const ranIds = []
|
|
1185
|
-
for (const item of
|
|
1306
|
+
for (const item of effectivePackages) {
|
|
1186
1307
|
const kept = previous.find(result => result.id === item.id)
|
|
1187
1308
|
if (targets && !targets.has(item.id) && kept) {
|
|
1188
1309
|
results.push(kept)
|
|
1189
1310
|
continue
|
|
1190
1311
|
}
|
|
1191
|
-
const override = overrides[item.id]
|
|
1192
|
-
const
|
|
1193
|
-
const
|
|
1312
|
+
const override = Object.hasOwn(overrides, item.id) ? overrides[item.id] : undefined
|
|
1313
|
+
const selection = packageSelectionFields(item)
|
|
1314
|
+
const target = selection.manualAssignment
|
|
1315
|
+
const provider = target?.kind === 'tool' ? 'official-tool' : target?.provider ?? override?.provider ?? item.recommendedProvider
|
|
1316
|
+
const model = target?.kind === 'tool' ? target.toolId : target?.model ?? override?.model ?? item.recommendedModel
|
|
1317
|
+
const identity = { ...selection, ...(item.type ? { type: item.type } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) }
|
|
1194
1318
|
if (runOptions.signal?.aborted) {
|
|
1195
1319
|
// Cancelled: later packages are not started (no CLI process, no API spend).
|
|
1196
|
-
const cancelled = { id: item.id, name: item.name, ok: false, provider, model,
|
|
1320
|
+
const cancelled = { id: item.id, name: item.name, ok: false, provider, model,
|
|
1321
|
+
channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm', ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}),
|
|
1322
|
+
...identity, answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
|
|
1197
1323
|
results.push(cancelled)
|
|
1198
1324
|
ranIds.push(item.id)
|
|
1199
1325
|
if (typeof onPackage === 'function') await onPackage(cancelled)
|
|
@@ -1205,7 +1331,8 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1205
1331
|
const waitingOn = unmet.filter(id => { const dep = results.find(result => result.id === id); return dep?.paused || dep?.waiting })
|
|
1206
1332
|
const blocked = {
|
|
1207
1333
|
id: item.id, name: item.name, ok: false, provider, model,
|
|
1208
|
-
channel: 'harness-llm',
|
|
1334
|
+
channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm', ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}),
|
|
1335
|
+
...identity, answer: '', fallback: null, blocked: true,
|
|
1209
1336
|
...(waitingOn.length ? { waiting: true } : {}),
|
|
1210
1337
|
error: waitingOn.length ? `等待上游步骤确认:${waitingOn.join('、')}` : `依赖未完成:${unmet.join('、')}`,
|
|
1211
1338
|
}
|
|
@@ -1214,17 +1341,45 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
|
|
|
1214
1341
|
if (typeof onPackage === 'function') await onPackage(blocked)
|
|
1215
1342
|
continue
|
|
1216
1343
|
}
|
|
1217
|
-
const
|
|
1344
|
+
const plannedRoute = routeList.find(candidate => candidate.provider === provider && candidate.model === model)
|
|
1218
1345
|
?? { provider, model }
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1346
|
+
const route = target?.kind === 'api' ? { ...plannedRoute, execution: 'api', manualPinned: true }
|
|
1347
|
+
: target?.kind === 'tool' ? { provider, model } : reasoningRouteForAssignment(plannedRoute, item, override)
|
|
1348
|
+
if (target?.kind === 'api') delete route.reasoningEffort
|
|
1349
|
+
const promptText = routingBypassed ? checkedTask(task) : packagePrompt(task, item, results)
|
|
1350
|
+
const startedAt = Date.now()
|
|
1351
|
+
let result
|
|
1352
|
+
if (target?.kind === 'tool') result = await runManualTool(target, promptText, runOptions)
|
|
1353
|
+
else if (target?.kind === 'api') result = { ...await useApi(runOptions.apiFallback, { route, task: promptText,
|
|
1354
|
+
signal: runOptions.signal, preference: 'api', reason: '按工作包手动选择模型目录 API。' }), fallback: null }
|
|
1355
|
+
else {
|
|
1356
|
+
const credentials = typeof credentialsFor === 'function' ? await credentialsFor(route) : runOptions.credentials ?? null
|
|
1357
|
+
result = await executeRouteWithBilling({
|
|
1358
|
+
...runOptions,
|
|
1359
|
+
billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
|
|
1360
|
+
credentials, route, task: promptText,
|
|
1361
|
+
})
|
|
1362
|
+
}
|
|
1363
|
+
const durationMs = Date.now() - startedAt
|
|
1364
|
+
const plannedEffort = typeof item.recommendedReasoningEffort === 'string' && item.recommendedReasoningEffort
|
|
1365
|
+
? item.recommendedReasoningEffort
|
|
1366
|
+
: null
|
|
1367
|
+
const effortFields = {
|
|
1368
|
+
...reasoningExecutionFields(result),
|
|
1369
|
+
...(plannedEffort && route.reasoningEffort !== plannedEffort
|
|
1370
|
+
? { plannedReasoningEffort: plannedEffort, reasoningEffortStatus: target?.kind === 'tool' ? 'unsupported' : 'unapplied' }
|
|
1371
|
+
: {}),
|
|
1372
|
+
}
|
|
1373
|
+
const entry = {
|
|
1374
|
+
id: item.id,
|
|
1375
|
+
name: item.name,
|
|
1376
|
+
...(override ? { reassigned: true } : {}),
|
|
1377
|
+
...result,
|
|
1378
|
+
...identity,
|
|
1379
|
+
...effortFields,
|
|
1380
|
+
...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
|
|
1381
|
+
inputChars: promptText.length,
|
|
1382
|
+
}
|
|
1228
1383
|
results.push(entry)
|
|
1229
1384
|
ranIds.push(item.id)
|
|
1230
1385
|
if (typeof onPackage === 'function') await onPackage(entry)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.1 — 2026-10-19
|
|
4
|
+
|
|
5
|
+
- Render the English and Chinese README accounting, planning, latency and assignment formulas with GitHub math fences instead of plain text code blocks. Keep ordinary equations on one line using short symbols explained in prose; reserve line breaks for the latency function's conditions. Escape currency dollar signs and document the historical P90 latency branch already used by the router.
|
|
6
|
+
- Guard all six paired formulas and translation hashes with `npm run check:readme`, the full test command, `prepack`, and a focused GitHub Actions workflow. Reject ordinary code fences, missing formulas, mismatched translations and ambiguous dollar signs.
|
|
7
|
+
- Require actual GitHub rendering and a narrow viewport check when changing README formulas. Routing and execution behavior are unchanged.
|
|
8
|
+
|
|
9
|
+
## 0.22.0 — 2026-10-19
|
|
10
|
+
|
|
11
|
+
- Add an explicit algorithm/manual assignment choice after task decomposition. Manually select an exact model API or registered official tool for every package, retain the original recommendation and dependencies, restore recommendations, and invalidate stale execution previews after edits.
|
|
12
|
+
- Validate the entire assignment before the first paid request, recompute per-package API estimates, check tool readiness, and execute the prepared plan. Manual APIs use adapter-default effort and stay pinned through budget checks and JSON validation; manual tools use their own default model and never silently fall back to API. Mixed execution is read-only; editable official teams retain their existing isolated-worktree lifecycle.
|
|
13
|
+
- Persist manual intent and original recommendations with actual channels, model evidence and costs. Retries retain selected targets, preserve completed upstream results even when their model leaves the catalog, and revalidate image capabilities after reassignment.
|
|
14
|
+
- Import explicit objective training evidence for task-related quality estimates and compare model/effort actions only with enough matching evidence. Learn empirical P90 token and duration resources from complete successful records, retaining conservative cold-start and missing-price fallbacks.
|
|
15
|
+
- Improve bounded DAG assignment search using future dependency boundaries, safe prefix dominance and optimistic handoff ranking. Beam search remains approximate, and synthetic/oracle tests do not establish production answer-quality or cost gains.
|
|
16
|
+
- Carry exact supported API effort IDs into execution, distinguish requested controls from unverified backend effort, and prevent cross-model effort leakage. Optional JSON-structure validation and one API upgrade default to off and respect subscription and budget restrictions.
|
|
17
|
+
- Retain earlier paid attempts when an upgrade fails or never starts; preserve unknown spending separately. Fix short dependency answers being replaced by truncation markers while keeping the total UTF-8 prompt limit.
|
|
18
|
+
|
|
19
|
+
Validation: 966 automated tests, 963 passed and 3 platform-dependent skips; 12 real Chromium interactions against offline UI fixtures passed. Frozen pnpm installation, client build/check and package verification passed. No new real provider or vendor CLI model calls were made. An existing Windows multi-process lock test had one transient EPERM during development; isolated rechecks and the final full suite passed, without a lock implementation change. See [release acceptance and limits](docs/releases/0.22.0-validation.md).
|
|
20
|
+
|
|
21
|
+
Fully quit Harness, including its tray and Host background processes, update the plugin to 0.22.0, and restart.
|
|
22
|
+
|
|
3
23
|
## 0.21.1 — 2026-10-18
|
|
4
24
|
|
|
5
25
|
- Honor Hermes custom program directories on Windows and POSIX through the official `-InstallDir` / `--dir` parameter. Place the checkout in the shared root's `hermes-agent` child and bind probes and terminals to its source-local `.hermes/bin` launcher; an old global command cannot satisfy a missing selected installation.
|
package/README.i18n.yaml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# README translation pairing for this repository.
|
|
2
2
|
# Reviewed together; Git blob hashes identify the exact synchronized texts.
|
|
3
|
-
README.md:
|
|
4
|
-
README.zh.md:
|
|
3
|
+
README.md: f0c518807e357f4d3637224a437b417e99698d63
|
|
4
|
+
README.zh.md: b1b0fee72beefd31cdb4085ee4902dd4b899b21d
|