@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.7

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.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
@@ -13,13 +13,18 @@
13
13
  * execution row's existing markup instead of extending that enum.
14
14
  */
15
15
  import {
16
+ MAX_INVALID_ATTEMPTS,
16
17
  MAX_QUALITY_ATTEMPTS,
17
18
  exceptionAttempts,
19
+ invalidAttempts,
18
20
  qualityAttempts,
19
21
  verificationPhase,
20
22
  verificationTotals,
23
+ type AcceptanceAuditRecord,
24
+ type AcceptanceCleanupRecord,
21
25
  type ExecutionVerification,
22
26
  type VerificationAttempt,
27
+ type VerificationCriterionFinding,
23
28
  } from '../../core/verification.ts'
24
29
  import { t } from '../locales.ts'
25
30
  import type { TaskBoardKey } from '../locales.ts'
@@ -46,24 +51,67 @@ const PHASE_STATUS_KEY: Record<ReturnType<typeof verificationPhase>, TaskBoardKe
46
51
  repairing: 'verify.status.failed',
47
52
  passed: 'verify.status.passed',
48
53
  failed: 'verify.status.failed',
54
+ invalid: 'verify.status.invalidHeld',
49
55
  }
50
56
 
51
- /** One recorded attempt: a quality verdict or an acceptance anomaly. */
52
- function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
53
- const exception = attempt.stage === 'exception'
57
+ /** Evidence location → locale key, for one structured finding's citation. */
58
+ const LOCATION_KEY: Record<VerificationCriterionFinding['location'], TaskBoardKey> = {
59
+ task: 'verify.location.task',
60
+ trajectory: 'verify.location.trajectory',
61
+ workspace: 'verify.location.workspace',
62
+ baseline: 'verify.location.baseline',
63
+ unknown: 'verify.location.unknown',
64
+ }
65
+
66
+ /** Invalid reason → locale key. */
67
+ function invalidReasonKey(attempt: VerificationAttempt): TaskBoardKey {
68
+ switch (attempt.invalidReason) {
69
+ case 'missing-finding': return 'verify.invalidReason.missing-finding'
70
+ case 'unlocatable-quote': return 'verify.invalidReason.unlocatable-quote'
71
+ case 'baseline-finding': return 'verify.invalidReason.baseline-finding'
72
+ case 'vacuous-finding': return 'verify.invalidReason.vacuous-finding'
73
+ case 'insufficient-evidence': return 'verify.invalidReason.insufficient-evidence'
74
+ default: return 'verify.invalidReason.unknown'
75
+ }
76
+ }
77
+
78
+ /** Audit stage → locale key (the lightweight credential after cleanup). */
79
+ const AUDIT_STAGE_KEY: Record<AcceptanceAuditRecord['stage'], TaskBoardKey> = {
80
+ quality: 'verify.cleanup.stage.quality',
81
+ invalid: 'verify.cleanup.stage.invalid',
82
+ exception: 'verify.cleanup.stage.exception',
83
+ budget: 'verify.cleanup.stage.budget',
84
+ }
85
+
86
+ /** One recorded attempt: a quality verdict, an invalid veto, an anomaly, or a budget stop. */
87
+ function AttemptRow({ attempt, detailRemoved }: { attempt: VerificationAttempt, detailRemoved: boolean }) {
88
+ // Everything that is not a quality verdict renders as an anomaly row: a
89
+ // budget stop carries no score either, and an invalid acceptance carries a
90
+ // score the board REFUSED to book, so showing either as a scored failure
91
+ // would read as a judgement the board never made. The invalid row gets its
92
+ // own badge and reason line, because it is a distinct outcome.
93
+ const invalid = attempt.stage === 'invalid'
94
+ const exception = attempt.stage !== 'quality' && !invalid
54
95
  const result = exception ? 'failed' : attempt.passed ? 'succeeded' : 'failed'
55
96
  return (
56
97
  <li className={css.executionRow} data-result={result}>
57
98
  <span className={css.executionBadge} data-result={result}>
58
- {exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
99
+ {invalid ? t('verify.status.invalid') : exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
59
100
  </span>
60
101
  <span className={css.executionTimes}>
61
- {exception
62
- ? t('verify.attemptException', { index: String(attempt.index) })
63
- : t('verify.attempt', { index: String(attempt.index) })}
102
+ {invalid
103
+ ? t('verify.attemptInvalid', { index: String(attempt.index) })
104
+ : exception
105
+ ? t('verify.attemptException', { index: String(attempt.index) })
106
+ : t('verify.attempt', { index: String(attempt.index) })}
64
107
  {' · '}{routeLabel(attempt)}
65
- {!exception && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
108
+ {!exception && !invalid && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
66
109
  </span>
110
+ {invalid && (
111
+ <span className={css.executionTimes}>
112
+ {t('verify.invalidDetail', { reason: t(invalidReasonKey(attempt)) })}
113
+ </span>
114
+ )}
67
115
  {attempt.error !== undefined && <span className={css.executionTimes}>{attempt.error}</span>}
68
116
  {!exception && (
69
117
  <>
@@ -88,6 +136,25 @@ function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
88
136
  </ul>
89
137
  </>
90
138
  )}
139
+ {(attempt.criterionFindings ?? []).length > 0 && (
140
+ <>
141
+ <span className={css.executionTimes}>{t('verify.criterionFindings')}</span>
142
+ <ul className={css.executionList}>
143
+ {(attempt.criterionFindings ?? []).map((finding, index) => (
144
+ <li key={findingKey(attempt, index)} className={css.executionTimes}>
145
+ {t('verify.criterionFindingFull', {
146
+ criterion: finding.criterionId,
147
+ location: t(LOCATION_KEY[finding.location]),
148
+ quote: finding.quote,
149
+ requirement: finding.requirement,
150
+ observation: finding.observation,
151
+ gap: finding.gap,
152
+ })}
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ </>
157
+ )}
91
158
  {attempt.findings.length > 0 && (
92
159
  <>
93
160
  <span className={css.executionTimes}>{t('verify.findings')}</span>
@@ -98,8 +165,12 @@ function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
98
165
  </ul>
99
166
  </>
100
167
  )}
101
- {!exception && attempt.findings.length === 0 && (
102
- <span className={css.executionTimes}>{t('verify.noFindings')}</span>
168
+ {!exception && !invalid && attempt.findings.length === 0 && (
169
+ // A cleaned attempt has no findings because the acceptance removed them,
170
+ // not because the judge gave none: saying "the judge reported no
171
+ // finding" here would invite exactly the misreading the cleanup rule
172
+ // exists to avoid.
173
+ <span className={css.executionTimes}>{t(detailRemoved ? 'verify.detailRemoved' : 'verify.noFindings')}</span>
103
174
  )}
104
175
  <span className={css.executionTimes}>
105
176
  {t('verify.evidence', {
@@ -145,16 +216,20 @@ export function VerificationReport({ verification }: { verification: ExecutionVe
145
216
  ? 'verify.applicability.skipped'
146
217
  : verification.applicability === 'goal-unavailable'
147
218
  ? 'verify.applicability.goalUnavailable'
148
- : verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
219
+ : verification.applicability === 'goal-disabled'
220
+ ? 'verify.applicability.goalDisabled'
221
+ : verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
149
222
  return (
150
223
  <div className={css.executionTimes}>
151
224
  <span className={css.executionBadge} data-result={phase === 'passed' ? 'succeeded' : phase === 'failed' ? 'failed' : undefined}>
152
225
  {t('verify.title')} · {t(PHASE_STATUS_KEY[phase])}
153
226
  </span>
154
227
  <span className={css.executionTimes}>
155
- {t('verify.counts', {
228
+ {t('verify.invalidCounts', {
156
229
  quality: String(qualityAttempts(verification).length),
157
230
  max: String(MAX_QUALITY_ATTEMPTS),
231
+ invalid: String(invalidAttempts(verification).length),
232
+ maxInvalid: String(MAX_INVALID_ATTEMPTS),
158
233
  exceptions: String(exceptionAttempts(verification).length),
159
234
  })}
160
235
  </span>
@@ -194,10 +269,54 @@ export function VerificationReport({ verification }: { verification: ExecutionVe
194
269
  {verification.attempts.length > 0 && (
195
270
  <ul className={css.executionList}>
196
271
  {[...verification.attempts].reverse().map(attempt => (
197
- <AttemptRow key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)} attempt={attempt} />
272
+ <AttemptRow
273
+ key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)}
274
+ attempt={attempt}
275
+ detailRemoved={verification.cleanup?.state === 'cleaned'}
276
+ />
198
277
  ))}
199
278
  </ul>
200
279
  )}
280
+ {verification.cleanup !== undefined && <CleanupNotice cleanup={verification.cleanup} />}
201
281
  </div>
202
282
  )
203
283
  }
284
+
285
+ /**
286
+ * Retention notice of one execution's acceptance detail.
287
+ *
288
+ * It exists so a reader never mistakes a cleaned-up report for a judge that
289
+ * produced no evidence: the notice says the detail WAS produced and was removed
290
+ * by the automatic cleanup, and lists the lightweight credential that survives.
291
+ * A pending or failed cleanup is reported the same way — never as a missing
292
+ * verdict and never as a revoked pass.
293
+ */
294
+ function CleanupNotice({ cleanup }: { cleanup: AcceptanceCleanupRecord }) {
295
+ const key: TaskBoardKey = cleanup.state === 'cleaned'
296
+ ? 'verify.cleanup.cleaned'
297
+ : cleanup.state === 'failed' ? 'verify.cleanup.failed' : 'verify.cleanup.pending'
298
+ return (
299
+ <>
300
+ <span className={css.executionTimes}>
301
+ {cleanup.state === 'failed'
302
+ ? t(key, { attempts: String(cleanup.attempts), error: cleanup.lastError ?? '' })
303
+ : t(key)}
304
+ </span>
305
+ {cleanup.audit.length > 0 && (
306
+ <ul className={css.executionList}>
307
+ {cleanup.audit.map((record, index) => (
308
+ <li key={record.at.toString(36) + '-' + record.stage + '-' + String(index)} className={css.executionTimes}>
309
+ {t('verify.cleanup.audit', {
310
+ index: String(index + 1),
311
+ stage: t(AUDIT_STAGE_KEY[record.stage]),
312
+ score: percent(record.score),
313
+ hash: record.evidenceHash.slice(0, 12),
314
+ })}
315
+ {record.findingSummary === undefined ? '' : ' · ' + record.findingSummary}
316
+ </li>
317
+ ))}
318
+ </ul>
319
+ )}
320
+ </>
321
+ )
322
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Execution model options: the picker rows built from the official
3
+ * `session/modelCatalog` reply, and the reasoning-effort derivation both
4
+ * execution-target editors read off them (the create dialog and the detail
5
+ * page share one contract here, so they cannot promise different levels).
6
+ *
7
+ * The pickers must offer each model's real reasoning levels, which only the
8
+ * catalog declares — so the reply is normalized once, here, instead of every
9
+ * picker re-reading the wire shape. The fallback discovery path carries no
10
+ * capability metadata at all; its rows stay silent about reasoning rather than
11
+ * claiming a model supports none.
12
+ */
13
+ import type { ExecutionModelOption } from '../../core/controller.ts'
14
+ import type { TaskBoardKey } from '../locales.ts'
15
+
16
+ /** One model's declared reasoning capability, as a picker row carries it. */
17
+ type ModelReasoning = NonNullable<ExecutionModelOption['reasoning']>
18
+
19
+ /** A plain JSON object, or undefined for anything else (including arrays). */
20
+ function record(value: unknown): Record<string, unknown> | undefined {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
22
+ }
23
+
24
+ /** A non-empty string, or undefined. */
25
+ function text(value: unknown): string | undefined {
26
+ return typeof value === 'string' && value !== '' ? value : undefined
27
+ }
28
+
29
+ /** The effort ids an adapter published for one model, in catalog order. */
30
+ function effortIdsOf(reasoning: Record<string, unknown>): string[] {
31
+ const efforts: string[] = []
32
+ for (const entry of Array.isArray(reasoning.efforts) ? reasoning.efforts : []) {
33
+ const id = text(record(entry)?.id)
34
+ if (id !== undefined && !efforts.includes(id)) efforts.push(id)
35
+ }
36
+ return efforts
37
+ }
38
+
39
+ /**
40
+ * What the catalog declares about one model's reasoning: a published
41
+ * `reasoning` block is what "this route reasons" means, and a model published
42
+ * without one is declared to have none. A model the reply never described at
43
+ * all is a different state, expressed by the row carrying no capability.
44
+ */
45
+ function reasoningOf(model: Record<string, unknown>): ModelReasoning {
46
+ const reasoning = record(model.reasoning)
47
+ if (reasoning === undefined) return { supported: false }
48
+ const efforts = effortIdsOf(reasoning)
49
+ return { supported: true, ...(efforts.length === 0 ? {} : { efforts }) }
50
+ }
51
+
52
+ /**
53
+ * One picker row, or undefined when the entry carries no usable id.
54
+ * @param withCapability - whether this reply declares reasoning metadata at
55
+ * all; the flat discovery list does not, so its rows stay silent about it.
56
+ */
57
+ function rowOf(model: Record<string, unknown>, provider: string | undefined, withCapability: boolean): ExecutionModelOption | undefined {
58
+ const id = text(model.id)
59
+ if (id === undefined) return undefined
60
+ const reasoning = withCapability ? reasoningOf(model) : undefined
61
+ return {
62
+ id: provider === undefined ? id : `${provider}/${id}`,
63
+ name: text(model.name) ?? id,
64
+ ...(provider === undefined ? {} : { provider }),
65
+ ...(reasoning === undefined ? {} : { reasoning }),
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Normalize a `session/modelCatalog` reply into picker rows: one row per model
71
+ * of every provider group, keyed by the qualified route an execution pins.
72
+ * @param value - the raw catalog reply.
73
+ * @returns the rows, empty when the reply is unreadable.
74
+ */
75
+ export function modelOptionsFromCatalog(value: unknown): ExecutionModelOption[] {
76
+ const options: ExecutionModelOption[] = []
77
+ const groups = record(value)?.groups
78
+ for (const entry of Array.isArray(groups) ? groups : []) {
79
+ const group = record(entry)
80
+ if (group === undefined) continue
81
+ const provider = text(group.id) ?? text(group.provider)
82
+ for (const item of Array.isArray(group.models) ? group.models : []) {
83
+ const model = record(item)
84
+ if (model === undefined) continue
85
+ const row = rowOf(model, provider, true)
86
+ if (row !== undefined) options.push(row)
87
+ }
88
+ }
89
+ return options
90
+ }
91
+
92
+ /**
93
+ * Normalize the flat `llm/discoverModels` list into picker rows. That reply
94
+ * names models only, so every row leaves the reasoning capability unknown.
95
+ * @param value - the raw discovery list.
96
+ * @returns the rows, empty when the list is unreadable.
97
+ */
98
+ export function modelOptionsFromDiscoveredModels(value: unknown): ExecutionModelOption[] {
99
+ const options: ExecutionModelOption[] = []
100
+ for (const item of Array.isArray(value) ? value : []) {
101
+ const model = record(item)
102
+ if (model === undefined) continue
103
+ const row = rowOf(model, undefined, false)
104
+ if (row !== undefined) options.push(row)
105
+ }
106
+ return options
107
+ }
108
+
109
+ /**
110
+ * The picker rows of a model roster: the qualified `provider/model` route as
111
+ * the value a pin stores, and the provider beside the model name so two
112
+ * providers offering one model name stay distinguishable.
113
+ * @param models - the roster rows.
114
+ * @returns the choice rows, in roster order.
115
+ */
116
+ export function modelChoiceRows(models: readonly ExecutionModelOption[]): Array<{ value: string; label: string }> {
117
+ return models.map(model => ({
118
+ value: model.id,
119
+ label: (model.provider === undefined ? '' : model.provider + ' · ') + (model.name ?? model.id),
120
+ }))
121
+ }
122
+
123
+ /**
124
+ * The capability row the roster publishes for one model route.
125
+ * @returns undefined when no model is pinned, or the roster does not describe
126
+ * the pinned one — an unknown model is not the same as a model without
127
+ * reasoning.
128
+ */
129
+ export function reasoningOfModel(
130
+ models: readonly ExecutionModelOption[],
131
+ modelId: string,
132
+ ): ExecutionModelOption['reasoning'] | undefined {
133
+ if (modelId === '') return undefined
134
+ return models.find(item => item.id === modelId)?.reasoning
135
+ }
136
+
137
+ /**
138
+ * The effort ids the picker offers for a model, in catalog order.
139
+ * @returns an empty list when the model is unpinned, unknown, declares no
140
+ * reasoning, or publishes no selectable level.
141
+ */
142
+ export function reasoningEffortsFor(models: readonly ExecutionModelOption[], modelId: string): string[] {
143
+ const reasoning = reasoningOfModel(models, modelId)
144
+ return reasoning?.supported === true ? [...reasoning.efforts ?? []] : []
145
+ }
146
+
147
+ /** Whether the effort picker may be operated: a pinned model with declared, selectable levels. */
148
+ export function reasoningPickerEnabled(models: readonly ExecutionModelOption[], modelId: string): boolean {
149
+ return reasoningEffortsFor(models, modelId).length > 0
150
+ }
151
+
152
+ /**
153
+ * The effort to keep when the picked model changes. Only a model that declares
154
+ * its levels can invalidate one: a model the roster cannot describe keeps the
155
+ * stored value, because the roster has no grounds to call it invalid.
156
+ * @param effort - the effort currently stored/selected ('' = follow the default).
157
+ * @returns the effort to keep.
158
+ */
159
+ export function reconcileReasoningEffort(
160
+ models: readonly ExecutionModelOption[],
161
+ modelId: string,
162
+ effort: string,
163
+ ): string {
164
+ if (effort === '') return ''
165
+ const reasoning = reasoningOfModel(models, modelId)
166
+ if (reasoning === undefined) return modelId === '' ? '' : effort
167
+ return reasoning.efforts?.includes(effort) === true ? effort : ''
168
+ }
169
+
170
+ /**
171
+ * The copy explaining why the picker is in its current state. One lookup for
172
+ * both editors, so the create dialog and the detail page never drift apart.
173
+ */
174
+ export function reasoningHintKey(models: readonly ExecutionModelOption[], modelId: string): TaskBoardKey {
175
+ if (modelId === '') return 'exec.reasoning.hint.pickModel'
176
+ if (reasoningOfModel(models, modelId) === undefined) return 'exec.reasoning.hint.unknownModel'
177
+ return reasoningPickerEnabled(models, modelId) ? 'exec.reasoning.hint.inherit' : 'exec.reasoning.hint.noEfforts'
178
+ }
@@ -42,3 +42,35 @@ export function inheritPresetLabel(presets: readonly ExecutionPresetOption[]): s
42
42
  if (fallback === undefined) return t('exec.mode.inherit')
43
43
  return t('exec.mode.inheritWith', { preset: presetLabel(fallback) })
44
44
  }
45
+
46
+ /** One preset row a picker renders: the stored id, its label, and whether it may be picked. */
47
+ export interface PresetChoiceRow {
48
+ /** The value a pin stores. */
49
+ id: string
50
+ /** Display label, already carrying the default and unavailable suffixes. */
51
+ label: string
52
+ /** True when the preset cannot compose a session: the row is shown but not selectable. */
53
+ disabled: boolean
54
+ }
55
+
56
+ /**
57
+ * The picker rows of one roster, split into the built-in and the user presets.
58
+ * Both pickers render these rows — the create dialog's mode select and the
59
+ * settings card's default-preset field — so their wording and their
60
+ * unavailable rows cannot drift apart.
61
+ * @param presets - the roster options.
62
+ * @returns one row list per group, in roster order.
63
+ */
64
+ export function presetChoiceRows(presets: readonly ExecutionPresetOption[]): { builtin: PresetChoiceRow[]; custom: PresetChoiceRow[] } {
65
+ const row = (preset: ExecutionPresetOption): PresetChoiceRow => ({
66
+ id: preset.id,
67
+ label: presetLabel(preset)
68
+ + (preset.isDefault ? t('exec.mode.defaultSuffix') : '')
69
+ + (preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''),
70
+ disabled: preset.broken !== undefined,
71
+ })
72
+ return {
73
+ builtin: presets.filter(preset => isBuiltinPreset(preset.id)).map(row),
74
+ custom: presets.filter(preset => !isBuiltinPreset(preset.id)).map(row),
75
+ }
76
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The agent-preset roster the execution-target pickers read: one read of the
3
+ * generated api-remotes face, and its normalization into the option rows the
4
+ * board controller and the settings card's new-card-defaults topic both
5
+ * consume.
6
+ *
7
+ * The roster is a runtime fact of the deployment, not ledger state. The
8
+ * namespace mounts with the official api-remotes assembly, so the read itself
9
+ * is wired through a scoped inject at the call site (see src/client/index.ts);
10
+ * this module owns only the shape both consumers agree on, so neither has to
11
+ * know how the wire face answers.
12
+ */
13
+ import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
14
+ import type { ExecutionPresetOption } from '../../core/controller.ts'
15
+
16
+ /** The generated Remote namespace the mode pickers read their roster from. */
17
+ export type PresetRemote = ClientRemote['agentPresets']
18
+
19
+ /** One agent-preset row the wire face answers with. */
20
+ export interface PresetRosterRow {
21
+ id: string
22
+ name?: string
23
+ description?: string
24
+ /** Why this preset cannot compose a session; absent when it can. */
25
+ broken?: string
26
+ isDefault: boolean
27
+ }
28
+
29
+ /**
30
+ * Read the agent-preset roster of one deployment. The caller passes the
31
+ * namespace resolved inside its scoped inject, so a host that serves no such
32
+ * namespace never reaches here; a host that serves it answers `ok: false`
33
+ * instead, which leaves the caller's options untouched.
34
+ * @param remote - the `remote.agentPresets` namespace.
35
+ * @returns the roster, or a failed read the caller ignores.
36
+ */
37
+ export async function readPresetRoster(remote: PresetRemote): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] }> {
38
+ const response = await remote.list()
39
+ if (!response.ok) return { ok: false, presets: [] }
40
+ return { ok: true, presets: response.value.presets }
41
+ }
42
+
43
+ /**
44
+ * Normalize one roster into the picker rows the UI consumes. A preset that
45
+ * cannot compose a session keeps its `broken` reason: the pickers show it and
46
+ * disable it, so a missing preset never looks like a missing roster.
47
+ * @param presets - the roster rows.
48
+ * @returns the picker options.
49
+ */
50
+ export function presetOptionsFromRoster(presets: readonly PresetRosterRow[]): ExecutionPresetOption[] {
51
+ return presets.map(preset => ({
52
+ id: preset.id,
53
+ name: preset.name,
54
+ description: preset.description,
55
+ broken: preset.broken,
56
+ isDefault: preset.isDefault,
57
+ }))
58
+ }
@@ -23,6 +23,9 @@ export const VERIFICATION_PHASE_KEY: Record<VerificationPhase, TaskBoardKey> = {
23
23
  repairing: 'running.repairing',
24
24
  passed: 'running.verificationPassed',
25
25
  failed: 'running.verificationFailed',
26
+ // Held for a human: the judge produced no usable veto, so this is NOT the
27
+ // quality-failure label.
28
+ invalid: 'running.verificationInvalid',
26
29
  }
27
30
 
28
31
  /**
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Team-run promotion for a parent card that just gained a subtask. A card with
3
+ * subtasks is exactly what the Agent Team mode is for, so the two join actions
4
+ * (create a subtask, link an existing task) opt the parent in — but only after
5
+ * the Host confirmed the join, and only where the deployment serves Agent
6
+ * Teams. A refused join changes nothing, and a parent that already opted in is
7
+ * left alone rather than rewritten.
8
+ *
9
+ * Nothing here runs on mount or from a render effect: an existing card is only
10
+ * ever touched by the user's own join action, so switching the option off by
11
+ * hand stays off.
12
+ */
13
+ import type { BoardController } from '../../core/controller.ts'
14
+ import type { TaskRecord } from '../../core/tasks.ts'
15
+
16
+ /**
17
+ * Whether a join would promote this parent: the deployment serves Agent Teams
18
+ * and the parent has not opted in yet.
19
+ * @param controller - the board controller (the snapshot carries both facts).
20
+ * @param parent - the task that gained a subtask, if there is one.
21
+ */
22
+ export function parentAwaitsTeamRunPromotion(controller: BoardController, parent: TaskRecord | undefined): boolean {
23
+ if (parent === undefined) return false
24
+ const snapshot = controller.getSnapshot()
25
+ if (snapshot.host?.teamRunAvailable !== true) return false
26
+ // The freshest record wins: the card may have been edited since this modal
27
+ // opened with its own copy.
28
+ return (snapshot.tasks.find(task => task.id === parent.id) ?? parent).teamRun !== true
29
+ }
30
+
31
+ /**
32
+ * Opt the parent in after the Host confirmed the join.
33
+ * @returns true when the opt-in was written; false when it did not apply.
34
+ */
35
+ export async function promoteParentForTeamRun(controller: BoardController, parent: TaskRecord | undefined): Promise<boolean> {
36
+ if (parent === undefined) return false
37
+ if (!parentAwaitsTeamRunPromotion(controller, parent)) return false
38
+ return await controller.updateTask(parent.id, { teamRun: true })
39
+ }