dsh-harbor-evolution 0.5.0 → 0.6.0

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/dashboard.js CHANGED
@@ -6,35 +6,38 @@ import { resolveWithin } from './evolution.js'
6
6
  const SUMMARY_NAME = 'evaluation-summary.json'
7
7
  const MAX_JOBS = 50
8
8
  const MAX_JSON_BYTES = 2 * 1024 * 1024
9
+ const MAX_SOURCE_BYTES = 128 * 1024
10
+ const MAX_PREVIEW_BYTES = 512 * 1024
9
11
  const MAX_TRIAL_LIMIT = 100
10
12
  const jsonCache = new Map()
11
13
  const SENSITIVE_KEY = /authorization|cookie|token|api[_-]?key|secret|password|request[_-]?headers/i
14
+ const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
12
15
 
13
16
  function safeSegment(value, label) {
14
17
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(value ?? ''))) throw new Error(`${label} is invalid`)
15
18
  return String(value)
16
19
  }
17
20
 
18
- function redact(value, depth = 0) {
21
+ function redact(value, depth = 0, maxText = 8_000) {
19
22
  if (depth > 10) return '[TRUNCATED depth]'
20
- if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1))
23
+ if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1, maxText))
21
24
  if (value && typeof value === 'object') {
22
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1)]))
25
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
23
26
  }
24
- if (typeof value === 'string' && value.length > 8_000) return `${value.slice(0, 8_000)}\n[TRUNCATED ${value.length - 8_000} chars]`
27
+ if (typeof value === 'string' && value.length > maxText) return `${value.slice(0, maxText)}\n[TRUNCATED ${value.length - maxText} chars]`
25
28
  return value
26
29
  }
27
30
 
28
- async function readJson(file, { maxBytes = MAX_JSON_BYTES } = {}) {
31
+ async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000 } = {}) {
29
32
  try {
30
33
  const details = await lstat(file)
31
34
  if (details.isSymbolicLink()) return { __readError: `${path.basename(file)} may not be a symlink` }
32
35
  if (!details.isFile()) return { __readError: `${path.basename(file)} is not a file` }
33
36
  if (details.size > maxBytes) return { __readError: `${path.basename(file)} exceeds ${maxBytes} bytes` }
34
37
  const cached = jsonCache.get(file)
35
- const identity = `${details.mtimeMs}:${details.size}`
38
+ const identity = `${details.mtimeMs}:${details.size}:${maxText}`
36
39
  if (cached?.identity === identity) return cached.value
37
- const value = redact(JSON.parse(await readFile(file, 'utf8')))
40
+ const value = redact(JSON.parse(await readFile(file, 'utf8')), 0, maxText)
38
41
  jsonCache.set(file, { identity, value })
39
42
  return value
40
43
  } catch (error) {
@@ -44,6 +47,21 @@ async function readJson(file, { maxBytes = MAX_JSON_BYTES } = {}) {
44
47
  }
45
48
  }
46
49
 
50
+ async function readSafeText(file, projectRoot) {
51
+ const resolved = path.resolve(file)
52
+ const root = path.resolve(projectRoot)
53
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return { error: 'source is outside projectRoot' }
54
+ try {
55
+ const details = await lstat(resolved)
56
+ if (!details.isFile() || details.isSymbolicLink()) return { error: 'source is not a safe file' }
57
+ if (details.size > MAX_SOURCE_BYTES) return { error: `source exceeds ${MAX_SOURCE_BYTES} bytes` }
58
+ const text = (await readFile(resolved, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
59
+ return { text: redact(text) }
60
+ } catch (error) {
61
+ return { error: error.code === 'ENOENT' ? 'source is unavailable' : 'source is unreadable' }
62
+ }
63
+ }
64
+
47
65
  async function directoryCheck(directory, { optional = false } = {}) {
48
66
  try {
49
67
  const details = await lstat(directory)
@@ -78,12 +96,24 @@ async function executableCheck(command) {
78
96
  }
79
97
  }
80
98
 
81
- function jobStatus(summary) {
82
- if (!summary) return 'pending'
83
- if (summary.__readError) return 'failed'
84
- if (Number(summary.n_trials ?? 0) > 0 && Number(summary.n_exceptions ?? 0) >= Number(summary.n_trials ?? 0)) return 'failed'
85
- if (Number(summary.n_exceptions ?? 0) > 0) return 'partial'
86
- return 'completed'
99
+ function isObject(value) {
100
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
101
+ }
102
+
103
+ function capabilityMap(summary, context, lifecycle, registry, stack) {
104
+ const contextV2 = context?.schema_version === 2
105
+ const scoreValidity = summary?.schema_version === 3
106
+ return {
107
+ contextV2,
108
+ trialLifecycle: lifecycle?.schema_version === 1,
109
+ scoreValidity,
110
+ evidenceProvenance: scoreValidity,
111
+ artifactRegistry: registry?.schema_version === 1,
112
+ compare: contextV2,
113
+ evaluatorGovernance: stack?.schema_version === 1,
114
+ gate: contextV2 && summary?.mode === 'promotion-eligible',
115
+ readOnlyLegacy: !contextV2,
116
+ }
87
117
  }
88
118
 
89
119
  function primaryMetric(summary, contract) {
@@ -93,27 +123,70 @@ function primaryMetric(summary, contract) {
93
123
  return entry ? { name: entry[0], value: entry[1] } : undefined
94
124
  }
95
125
 
126
+ function progressView(summary, lifecycle, updatedAt) {
127
+ const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? 0)
128
+ const lifecycleTrials = selectedLifecycleTrials(lifecycle)
129
+ const completed = lifecycle
130
+ ? lifecycleTrials.filter(item => item.terminal).length
131
+ : Number(summary?.n_completed_trials ?? summary?.n_discovered_trials ?? summary?.n_trials ?? 0)
132
+ const active = lifecycle ? lifecycleTrials.some(item => !item.terminal) : !summary
133
+ const lastProgressAt = lifecycle?.updated_at ?? updatedAt
134
+ const ageMs = Math.max(0, Date.now() - Date.parse(lastProgressAt || updatedAt))
135
+ const errorCount = Number(summary?.n_infrastructure_exceptions ?? 0) + Number(summary?.n_evaluation_exceptions ?? 0)
136
+ const health = errorCount > 0 ? 'attention' : active && ageMs > 60_000 ? 'stalled' : active ? 'healthy' : 'completed'
137
+ return {
138
+ total,
139
+ completed: Math.min(completed, total || completed),
140
+ active,
141
+ percent: total ? Math.min(100, Math.round((completed / total) * 100)) : 0,
142
+ lastProgressAt,
143
+ health,
144
+ }
145
+ }
146
+
147
+ function jobStatus(summary, lifecycle, progress) {
148
+ if (summary?.__readError) return 'failed'
149
+ if (!summary && !lifecycle) return 'pending'
150
+ if (progress.active) return 'running'
151
+ if (!summary && lifecycle) return 'running'
152
+ if (Number(summary.n_infrastructure_exceptions ?? summary.n_exceptions ?? 0) > 0 || Number(summary.n_evaluation_exceptions ?? 0) > 0) return 'partial'
153
+ if (Number(summary.n_invalid_scores ?? 0) > 0) return 'attention'
154
+ return 'completed'
155
+ }
156
+
96
157
  async function readJob(jobsDir, entry, details) {
97
158
  const directory = path.join(jobsDir, entry.name)
98
- const [summary, context, promotion, contract] = await Promise.all([
159
+ const [summary, contextFile, promotion, contract, lifecycle, registry, stack] = await Promise.all([
99
160
  readJson(path.join(directory, SUMMARY_NAME)),
100
161
  readJson(path.join(directory, 'evaluation-context.json')),
101
162
  readJson(path.join(directory, 'promotion-report.json')),
102
163
  readJson(path.join(directory, 'evaluation-contract.json')),
164
+ readJson(path.join(directory, 'trial-lifecycle.json')),
165
+ readJson(path.join(directory, 'artifact-registry.json')),
166
+ readJson(path.join(directory, 'evaluation-stack-manifest.json')),
103
167
  ])
104
- const evaluationContext = summary?.evaluation_context ?? context
105
- if (evaluationContext?.schema_version !== 2) return undefined
168
+ const evaluationContext = summary?.evaluation_context ?? contextFile
169
+ if (!evaluationContext && !summary && !lifecycle) return undefined
170
+ const updatedAt = details.mtime.toISOString()
171
+ const progress = progressView(summary, lifecycle, updatedAt)
172
+ const capabilities = capabilityMap(summary, evaluationContext, lifecycle, registry, stack)
106
173
  return {
107
174
  name: entry.name,
108
- updatedAt: details.mtime.toISOString(),
109
- status: jobStatus(summary),
110
- mode: summary?.mode,
111
- nTrials: Number(summary?.n_trials ?? 0),
175
+ updatedAt,
176
+ status: jobStatus(summary, lifecycle, progress),
177
+ mode: summary?.mode ?? evaluationContext?.mode,
178
+ nTrials: progress.total,
179
+ nDiscoveredTrials: Number(summary?.n_discovered_trials ?? lifecycle?.attempt_count ?? 0),
180
+ nValidScores: summary?.n_valid_scores,
181
+ nInvalidScores: summary?.n_invalid_scores,
112
182
  nExceptions: Number(summary?.n_exceptions ?? 0),
113
183
  primaryMetric: primaryMetric(summary, contract),
114
184
  metrics: summary?.metrics ?? {},
115
- candidate: summary?.candidate,
185
+ candidate: summary?.candidate ?? evaluationContext?.candidate,
186
+ dataset: evaluationContext?.dataset,
116
187
  evaluationContext,
188
+ progress,
189
+ capabilities,
117
190
  artifactValidation: summary?.artifact_validation,
118
191
  promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job } : undefined,
119
192
  readError: summary?.__readError,
@@ -157,15 +230,15 @@ export async function readDashboardSnapshot(config, metadata = {}) {
157
230
  const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
158
231
  const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
159
232
  return {
160
- schemaVersion: 2,
233
+ schemaVersion: 3,
161
234
  generatedAt: new Date().toISOString(),
162
235
  pluginVersion: metadata.pluginVersion ?? 'development',
163
236
  config: { jobsDir: config.jobsDir, dshVersion: config.dshVersion, agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
164
237
  checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
165
238
  overview: {
166
239
  totalJobs: jobs.length,
167
- completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0),
168
- activeJobs: counts.pending ?? 0,
240
+ completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
241
+ activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
169
242
  failedJobs: counts.failed ?? 0,
170
243
  latestMetric,
171
244
  },
@@ -177,11 +250,15 @@ const DETAIL_ARTIFACTS = {
177
250
  summary: 'evaluation-summary.json',
178
251
  candidate: 'candidate-manifest.json',
179
252
  dataset: 'dataset-manifest.json',
253
+ datasetPreview: 'dataset-preview.json',
180
254
  stack: 'evaluation-stack-manifest.json',
181
255
  context: 'evaluation-context.json',
182
256
  contract: 'evaluation-contract.json',
183
257
  doctor: 'architecture-doctor.json',
184
258
  population: 'population-report.json',
259
+ lifecycle: 'trial-lifecycle.json',
260
+ registry: 'artifact-registry.json',
261
+ diagnosis: 'diagnosis-report.json',
185
262
  optimization: 'optimization-report.json',
186
263
  promotion: 'promotion-report.json',
187
264
  }
@@ -190,28 +267,21 @@ function schemaIssue(key, value) {
190
267
  if (value === undefined) return undefined
191
268
  if (value?.__readError) return value.__readError
192
269
  if (!isObject(value)) return 'artifact must be an object'
193
- const version = { summary: 2, candidate: 1, dataset: 1, stack: 1, context: 2, contract: 1, doctor: 1, population: 1, optimization: 1, promotion: 2 }[key]
194
- if (value.schema_version !== version) return `schema_version must be ${version}`
270
+ const versions = {
271
+ summary: [2, 3], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], context: [1, 2], contract: [1],
272
+ doctor: [1], population: [1, 2], lifecycle: [1], registry: [1], diagnosis: [1], optimization: [1, 2], promotion: [2],
273
+ }[key]
274
+ if (versions && !versions.includes(value.schema_version)) return `schema_version must be one of ${versions.join(', ')}`
195
275
  const required = {
196
- summary: ['job', 'candidate', 'evaluation_context', 'metrics'],
197
- candidate: ['candidate_id', 'version', 'digest'],
198
- dataset: ['dataset_id', 'version', 'source_digest', 'tasks'],
199
- stack: ['stack_id', 'version', 'digest', 'comparison_digest', 'components', 'judge'],
200
- context: ['digest', 'full_digest', 'candidate', 'dataset', 'evaluation_stack', 'runtime'],
201
- contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
202
- doctor: ['promotion_ready', 'findings'],
203
- population: ['population_size', 'groups', 'metrics'],
204
- optimization: ['hypotheses'],
205
- promotion: ['decision', 'reasons', 'policy_digest'],
276
+ summary: ['job', 'metrics'], candidate: ['candidate_id', 'version', 'digest'], dataset: ['dataset_id', 'version', 'source_digest', 'tasks'], datasetPreview: ['dataset_id', 'version', 'source_digest', 'tasks'],
277
+ stack: ['stack_id', 'version', 'digest', 'components', 'judge'], context: ['digest'], contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
278
+ doctor: ['promotion_ready', 'findings'], population: ['population_size', 'groups', 'metrics'], lifecycle: ['dataset_total', 'trials'],
279
+ registry: ['artifacts'], diagnosis: ['diagnoses'], optimization: ['hypotheses'], promotion: ['decision', 'reasons', 'policy_digest'],
206
280
  }[key] ?? []
207
281
  const missing = required.filter(field => value[field] === undefined)
208
282
  return missing.length ? `missing fields: ${missing.join(', ')}` : undefined
209
283
  }
210
284
 
211
- function isObject(value) {
212
- return value !== null && typeof value === 'object' && !Array.isArray(value)
213
- }
214
-
215
285
  export async function readJobDetail(config, args) {
216
286
  const job = safeSegment(args.job, 'job')
217
287
  const directory = jobDirectory(config, job)
@@ -225,10 +295,105 @@ export async function readJobDetail(config, args) {
225
295
  }
226
296
  const validation = Object.fromEntries(Object.entries(artifacts).map(([key, value]) => {
227
297
  const issue = schemaIssue(key, value)
228
- return [key, value === undefined ? { status: 'missing' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
298
+ return [key, value === undefined ? { status: 'unavailable', reason: 'capability-not-produced' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
229
299
  }))
230
- if (validation.context.status !== 'valid') throw new Error('Job is not a Context v2 evaluation')
231
- return { schemaVersion: 1, job, artifacts, validation }
300
+ const context = artifacts.context ?? values[Object.keys(DETAIL_ARTIFACTS).indexOf('context')]
301
+ const capabilities = capabilityMap(
302
+ values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')], context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
303
+ )
304
+ return { schemaVersion: 2, job, capabilities, artifacts, validation }
305
+ }
306
+
307
+ function selectedLifecycleTrials(lifecycle) {
308
+ const selected = new Map()
309
+ for (const trial of lifecycle?.trials ?? []) {
310
+ const key = Number(trial.dataset_order ?? selected.size)
311
+ if (!selected.has(key) || Number(trial.attempt ?? 1) > Number(selected.get(key).attempt ?? 1)) selected.set(key, trial)
312
+ }
313
+ return [...selected.values()].sort((left, right) => Number(left.dataset_order ?? 0) - Number(right.dataset_order ?? 0))
314
+ }
315
+
316
+ function normalizeTrial(trial, order) {
317
+ const score = trial.score ?? { value: undefined, valid: trial.exception ? false : true, invalid_reasons: trial.exception ? ['infrastructure-error'] : [] }
318
+ const datasetOrder = Number(trial.datasetOrder ?? trial.dataset_order ?? order)
319
+ return {
320
+ id: trial.id ?? trial.execution_id ?? `dataset-${datasetOrder}`,
321
+ name: trial.name ?? trial.trial_name ?? trial.dataset_trial ?? trial.trial,
322
+ datasetTrial: trial.datasetTrial ?? trial.dataset_trial ?? trial.trial,
323
+ datasetOrder,
324
+ attempt: Number(trial.attempt ?? 1),
325
+ status: trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed'),
326
+ terminal: trial.terminal ?? true,
327
+ updatedAt: trial.updatedAt ?? trial.updated_at,
328
+ score,
329
+ rewards: trial.rewards ?? {},
330
+ requirements: trial.requirements,
331
+ population: trial.population ?? {},
332
+ evidenceAvailable: Boolean(trial.evidenceAvailable ?? trial.terminal),
333
+ exception: trial.exception ? { type: trial.exception.type, classification: trial.exception.classification } : undefined,
334
+ }
335
+ }
336
+
337
+ async function jobTrials(config, job) {
338
+ const directory = jobDirectory(config, job)
339
+ const [summary, lifecycle] = await Promise.all([
340
+ readJson(path.join(directory, SUMMARY_NAME)),
341
+ readJson(path.join(directory, 'trial-lifecycle.json')),
342
+ ])
343
+ if ((!summary || summary.__readError) && (!lifecycle || lifecycle.__readError)) throw new Error('Job progress is unavailable')
344
+ const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
345
+ if (!lifecycle?.trials) return { trials: summaryTrials, total: Number(summary?.n_trials ?? summaryTrials.length), lifecycle }
346
+ const byExecution = new Map(summaryTrials.map(item => [String(item.id), item]))
347
+ const byDataset = new Map(summaryTrials.map(item => [String(item.datasetTrial), item]))
348
+ const trials = selectedLifecycleTrials(lifecycle).map((item, index) => {
349
+ const evaluated = byExecution.get(String(item.execution_id)) ?? byDataset.get(String(item.dataset_trial))
350
+ return normalizeTrial({ ...item, ...evaluated, dataset_order: item.dataset_order, attempt: item.attempt }, index)
351
+ })
352
+ return { trials, total: Number(lifecycle.dataset_total ?? summary?.n_trials ?? trials.length), lifecycle }
353
+ }
354
+
355
+ function datasetTaskAliases(task) {
356
+ const values = [task?.id, task?.path, task?.metadata?.task_name]
357
+ const aliases = new Set()
358
+ for (const value of values) {
359
+ const normalized = String(value ?? '').trim().replace(/^\/+|\/+$/g, '')
360
+ if (!normalized || normalized === '.') continue
361
+ aliases.add(normalized)
362
+ aliases.add(normalized.split('/').at(-1))
363
+ }
364
+ return aliases
365
+ }
366
+
367
+ function taskDisplayName(task) {
368
+ const direct = task?.query ?? task?.metadata?.query
369
+ if (typeof direct === 'string' && direct.trim()) return direct.trim()
370
+ if (typeof task?.instruction === 'string') {
371
+ const lines = task.instruction.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'))
372
+ const labeled = lines.find(line => /^(?:query|question|问题|任务)\s*[::]/i.test(line))
373
+ const text = (labeled ?? lines[0] ?? '').replace(/^(?:query|question|问题|任务)\s*[::]\s*/i, '')
374
+ if (text) return text.length > 120 ? `${text.slice(0, 117)}…` : text
375
+ }
376
+ return String(task?.id ?? task?.path ?? '')
377
+ }
378
+
379
+ async function enrichTrialsWithDataset(config, job, trials) {
380
+ let preview
381
+ try { preview = await readDatasetPreview(config, { job }) } catch { return trials }
382
+ const byAlias = new Map()
383
+ for (const [datasetOrder, task] of (preview?.tasks ?? []).entries()) {
384
+ for (const alias of datasetTaskAliases(task)) byAlias.set(alias, { task, datasetOrder })
385
+ }
386
+ return trials.map(trial => {
387
+ const normalized = String(trial.datasetTrial ?? '').replace(/^\/+|\/+$/g, '')
388
+ const matched = byAlias.get(normalized) ?? byAlias.get(normalized.split('/').at(-1))
389
+ if (!matched) return { ...trial, displayName: trial.datasetTrial ?? trial.name }
390
+ return {
391
+ ...trial,
392
+ displayName: taskDisplayName(matched.task) || trial.datasetTrial || trial.name,
393
+ taskId: matched.task.id,
394
+ datasetOrder: matched.datasetOrder,
395
+ }
396
+ })
232
397
  }
233
398
 
234
399
  export async function readTrialsPage(config, args) {
@@ -237,30 +402,319 @@ export async function readTrialsPage(config, args) {
237
402
  const limit = Math.min(MAX_TRIAL_LIMIT, Math.max(1, Number.parseInt(args.limit ?? 50, 10) || 50))
238
403
  const query = String(args.query ?? '').trim().toLowerCase()
239
404
  const status = String(args.status ?? '')
240
- const summary = await readJson(path.join(jobDirectory(config, job), SUMMARY_NAME))
241
- if (!summary || summary.__readError) throw new Error('Job summary is unavailable')
242
- let trials = Array.isArray(summary.trials) ? summary.trials : []
243
- if (query) trials = trials.filter(trial => `${trial.id ?? ''} ${trial.name ?? ''}`.toLowerCase().includes(query))
244
- if (status) trials = trials.filter(trial => (trial.exception ? 'infrastructure-error' : 'assessed') === status)
245
- const items = trials.slice(offset, offset + limit).map(trial => ({
246
- id: trial.id ?? trial.name,
247
- name: trial.name,
248
- status: trial.exception ? 'infrastructure-error' : 'assessed',
249
- rewards: trial.rewards ?? {},
250
- exception: trial.exception ? { type: trial.exception.type, classification: trial.exception.classification } : undefined,
251
- }))
252
- return { schemaVersion: 1, job, offset, limit, total: trials.length, items, hasMore: offset + items.length < trials.length }
405
+ const validity = String(args.validity ?? '')
406
+ const evidence = String(args.evidence ?? '')
407
+ const sort = String(args.sort ?? 'dataset-order')
408
+ const source = await jobTrials(config, job)
409
+ let trials = await enrichTrialsWithDataset(config, job, source.trials)
410
+ if (query) trials = trials.filter(trial => `${trial.id ?? ''} ${trial.displayName ?? ''} ${trial.name ?? ''} ${trial.datasetTrial ?? ''}`.toLowerCase().includes(query))
411
+ if (status) trials = trials.filter(trial => trial.status === status)
412
+ if (validity) trials = trials.filter(trial => String(Boolean(trial.score?.valid)) === validity)
413
+ if (evidence) trials = trials.filter(trial => String(Boolean(trial.evidenceAvailable)) === evidence)
414
+ if (sort === 'dataset-order') trials = [...trials].sort((a, b) => a.datasetOrder - b.datasetOrder || a.attempt - b.attempt)
415
+ if (sort === 'latest-completed') trials = [...trials].sort((a, b) => Date.parse(b.updatedAt ?? 0) - Date.parse(a.updatedAt ?? 0))
416
+ if (sort === 'lowest-score') trials = [...trials].sort((a, b) => (a.score?.value ?? Number.POSITIVE_INFINITY) - (b.score?.value ?? Number.POSITIVE_INFINITY))
417
+ if (sort === 'errors') trials = [...trials].sort((a, b) => Number(!b.exception && b.score?.valid !== false) - Number(!a.exception && a.score?.valid !== false))
418
+ const items = trials.slice(offset, offset + limit)
419
+ return {
420
+ schemaVersion: 2, job, offset, limit, total: trials.length, datasetTotal: source.total,
421
+ sort, items, hasMore: offset + items.length < trials.length, updatedAt: source.lifecycle?.updated_at,
422
+ }
253
423
  }
254
424
 
255
425
  function assessmentName(id) {
256
426
  return `${String(id).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '') || 'trial'}.json`
257
427
  }
258
428
 
429
+ function previewFromOutput(output, evidence = []) {
430
+ if (typeof output === 'string' && output.trim()) return { kind: 'document', format: 'text', title: 'Agent output', content: output, provenance: evidence }
431
+ if (!isObject(output)) return undefined
432
+ if (typeof output.kind === 'string' && 'content' in output) return { ...output, provenance: evidence }
433
+ const url = output.page_url ?? output.preview_url ?? output.url
434
+ if (typeof url === 'string' && /^(https?:\/\/|\/)/.test(url)) return { kind: 'page', format: 'url', title: output.title ?? 'Generated page', url, content: output, provenance: evidence }
435
+ if (typeof output.html === 'string') return { kind: 'page', format: 'html', title: output.title ?? 'Generated page', content: output.html, provenance: evidence }
436
+ if (['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof output[key] === 'string')) return { kind: 'document', format: 'json', title: output.title ?? 'Generated document', content: output, provenance: evidence }
437
+ if ('metadata' in output && !['answer', 'content', 'report', 'markdown', 'text'].some(key => key in output)) return undefined
438
+ return { kind: 'structured', format: 'json', title: output.title ?? 'Structured output', content: output, provenance: evidence }
439
+ }
440
+
441
+ async function previewFromTrialFiles(directory, lifecycle) {
442
+ let trialName
443
+ try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
444
+ const trialDirectory = path.join(directory, trialName)
445
+ const check = await directoryCheck(trialDirectory)
446
+ if (check.status !== 'ok') return undefined
447
+ const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000 })
448
+ const candidates = []
449
+ for (const entry of Array.isArray(manifest) ? manifest : []) {
450
+ if (!isObject(entry) || !['ok', 'collected', 'mounted'].includes(entry.status) || typeof entry.destination !== 'string' || !entry.destination.startsWith('artifacts/')) continue
451
+ const candidate = path.resolve(trialDirectory, entry.destination)
452
+ const artifactRoot = path.resolve(trialDirectory, 'artifacts')
453
+ if (candidate.startsWith(`${artifactRoot}${path.sep}`)) candidates.push(candidate)
454
+ }
455
+ const priority = new Map([['.html', 0], ['.htm', 0], ['.md', 1], ['.markdown', 1], ['.txt', 2], ['.json', 3]])
456
+ candidates.sort((a, b) => (priority.get(path.extname(a).toLowerCase()) ?? 99) - (priority.get(path.extname(b).toLowerCase()) ?? 99) || a.localeCompare(b))
457
+ for (const candidate of candidates) {
458
+ try {
459
+ const details = await lstat(candidate)
460
+ if (!details.isFile() || details.isSymbolicLink() || details.size > MAX_PREVIEW_BYTES) continue
461
+ const format = path.extname(candidate).toLowerCase()
462
+ const text = (await readFile(candidate, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
463
+ const content = format === '.json' ? redact(JSON.parse(text), 0, 128_000) : redact(text, 0, 128_000)
464
+ const kind = ['.html', '.htm'].includes(format)
465
+ ? 'page'
466
+ : format === '.json' && !(isObject(content) && ['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof content[key] === 'string'))
467
+ ? 'structured'
468
+ : 'document'
469
+ return { kind, format: format.replace('.', '') || 'text', title: path.basename(candidate), content, artifact_ref: path.relative(trialDirectory, candidate), provenance: [{ label: 'Agent Artifact', kind: 'agent-artifact', artifact_ref: path.relative(trialDirectory, candidate) }] }
470
+ } catch { /* try the next declared artifact */ }
471
+ }
472
+ const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000 })
473
+ const messages = (trajectory?.steps ?? []).filter(step => step?.source === 'agent' && typeof step.message === 'string').map(step => step.message)
474
+ return messages.length ? { kind: 'document', format: 'text', title: 'Agent final response', content: messages.at(-1), artifact_ref: 'agent/trajectory.json', provenance: [{ label: 'ACP Final Response', kind: 'acp-final-response', artifact_ref: 'agent/trajectory.json' }] } : undefined
475
+ }
476
+
477
+ async function evaluatorResultFromTrialFiles(directory, lifecycle) {
478
+ let trialName
479
+ try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
480
+ const trialDirectory = path.join(directory, trialName)
481
+ const check = await directoryCheck(trialDirectory)
482
+ if (check.status !== 'ok') return undefined
483
+ const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000 })
484
+ return result && !result.__readError ? result : undefined
485
+ }
486
+
487
+ function enrichAssessmentWithEvaluator(assessment, evaluatorResult) {
488
+ if (!assessment) return assessment
489
+ const byCriterion = new Map((evaluatorResult?.criteria ?? []).filter(isObject).map(item => [String(item.id), item]))
490
+ const criteria = (assessment.criteria ?? []).map(item => {
491
+ const evaluator = byCriterion.get(String(item.id))
492
+ return evaluator ? { ...item, reason: evaluator.reason ?? item.reason, recommendation: evaluator.recommendation ?? item.recommendation } : item
493
+ })
494
+ const evaluatorRecommendations = (evaluatorResult?.recommendations ?? []).map(item => isObject(item) ? item : { message: String(item) })
495
+ return { ...assessment, criteria, recommendations: [...(assessment.recommendations ?? []), ...evaluatorRecommendations] }
496
+ }
497
+
498
+ async function datasetRoots(directory, projectRoot) {
499
+ const entries = await readdir(directory, { withFileTypes: true })
500
+ const roots = []
501
+ for (const entry of entries.filter(item => item.isDirectory() && !item.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
502
+ const result = await readJson(path.join(directory, entry.name, 'result.json'))
503
+ const candidate = result?.task_id?.path
504
+ if (typeof candidate !== 'string') continue
505
+ try {
506
+ const resolved = resolveWithin(projectRoot, path.relative(projectRoot, candidate), 'task path')
507
+ const check = await directoryCheck(resolved)
508
+ if (check.status === 'ok') roots.push(resolved)
509
+ } catch { /* ignore historical out-of-root task sources */ }
510
+ }
511
+ return [...new Set(roots)]
512
+ }
513
+
514
+ export async function readDatasetPreview(config, args) {
515
+ const job = safeSegment(args.job, 'job')
516
+ const directory = jobDirectory(config, job)
517
+ const snapshot = await readJson(path.join(directory, 'dataset-preview.json'), { maxBytes: MAX_JSON_BYTES, maxText: 128_000 })
518
+ if (snapshot && !snapshot.__readError) return { ...snapshot, source: 'job-snapshot' }
519
+ const manifest = await readJson(path.join(directory, 'dataset-manifest.json'))
520
+ if (!manifest || manifest.__readError) throw new Error('Dataset Manifest is unavailable')
521
+ const roots = await datasetRoots(directory, config.projectRoot)
522
+ const tasks = []
523
+ for (const [index, task] of (manifest.tasks ?? []).entries()) {
524
+ const root = roots[Math.min(index, Math.max(0, roots.length - 1))]
525
+ let instruction = { error: 'instruction source is unavailable for this historical Job' }
526
+ if (root && typeof task?.instruction === 'string') {
527
+ try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'), config.projectRoot) } catch { instruction = { error: 'instruction path is invalid' } }
528
+ }
529
+ tasks.push({ id: task?.id ?? `task-${index + 1}`, path: task?.path ?? '.', instruction_file: task?.instruction, instruction: instruction.text, instruction_error: instruction.error, instruction_truncated: Boolean(instruction.text?.includes('[TRUNCATED')) })
530
+ }
531
+ return { schema_version: 1, dataset_id: manifest.dataset_id, version: manifest.version, source_digest: manifest.source_digest, task_count: tasks.length, tasks, source: 'historical-source-fallback' }
532
+ }
533
+
259
534
  export async function readTrialDetail(config, args) {
260
535
  const job = safeSegment(args.job, 'job')
261
536
  const trial = safeSegment(args.trial, 'trial')
262
537
  const directory = jobDirectory(config, job)
263
- const assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)))
264
- if (!assessment || assessment.__readError) throw new Error('Trial assessment not found')
265
- return { schemaVersion: 1, job, trial, assessment }
538
+ let assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)))
539
+ const source = await jobTrials(config, job)
540
+ const lifecycle = source.trials.find(item => String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial)
541
+ if ((!assessment || assessment.__readError) && lifecycle?.id && String(lifecycle.id) !== trial) {
542
+ assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)))
543
+ }
544
+ if (assessment?.__readError) throw new Error('Trial assessment is invalid')
545
+ if (!assessment && !lifecycle) throw new Error('Trial not found')
546
+ assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle))
547
+ const assessmentPreview = previewFromOutput(assessment?.output, assessment?.evidence_provenance)
548
+ const realAssessmentOutput = assessment?.evidence_provenance?.some(item => item?.kind === 'real-renderer' || item?.kind === 'agent-artifact')
549
+ const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle)
550
+ const preview = realAssessmentOutput ? assessmentPreview : filePreview ?? assessmentPreview
551
+ return {
552
+ schemaVersion: 2, job, trial, lifecycle,
553
+ status: lifecycle?.status ?? assessment?.status,
554
+ assessment,
555
+ preview,
556
+ capability: assessment ? 'assessment-available' : 'running-evidence-not-yet-available',
557
+ }
558
+ }
559
+
560
+ export async function readJobProgress(config, args) {
561
+ const job = safeSegment(args.job, 'job')
562
+ const since = args.since ? Date.parse(args.since) : 0
563
+ const source = await jobTrials(config, job)
564
+ const changed = source.trials.filter(item => !since || Date.parse(item.updatedAt ?? 0) > since)
565
+ return {
566
+ schemaVersion: 1,
567
+ job,
568
+ updatedAt: source.lifecycle?.updated_at ?? new Date().toISOString(),
569
+ datasetTotal: source.total,
570
+ counts: source.lifecycle?.counts ?? {},
571
+ changed,
572
+ }
573
+ }
574
+
575
+ export async function readMetaEvaluation(config, args = {}) {
576
+ const evaluationRoot = resolveWithin(config.projectRoot, args.evaluationRoot ?? '.', 'evaluationRoot')
577
+ const groundTruth = await readJson(path.join(evaluationRoot, '.harbor', 'ground-truth.json'), { maxText: 64_000 })
578
+ const report = await readJson(path.join(evaluationRoot, '.harbor', 'meta-evaluation-report.json'), { maxText: 64_000 })
579
+ const availableGroundTruth = groundTruth && !groundTruth.__readError
580
+ const availableReport = report && !report.__readError
581
+ const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
582
+ return {
583
+ schemaVersion: 1,
584
+ evaluationRoot: path.relative(config.projectRoot, evaluationRoot) || '.',
585
+ status: availableReport ? 'evaluated' : availableGroundTruth ? (cases.length ? 'ground-truth-ready' : 'ground-truth-draft') : 'ground-truth-required',
586
+ groundTruth: availableGroundTruth ? {
587
+ id: groundTruth.ground_truth_id,
588
+ version: groundTruth.version,
589
+ source: groundTruth.source,
590
+ criteria: groundTruth.criteria ?? [],
591
+ caseCount: cases.length,
592
+ badcaseCount: cases.filter(item => item?.badcase).length,
593
+ path: path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'ground-truth.json')),
594
+ } : undefined,
595
+ report: availableReport ? report : undefined,
596
+ workflow: {
597
+ candidate: 'Evaluator / Rubric / Judge identity',
598
+ dataset: 'Fixed artifacts plus independent Ground Truth',
599
+ output: 'Repeated evaluator-observations/v1',
600
+ verifier: 'ESF / SCE / RCR reducer',
601
+ automaticAgentBaseline: false,
602
+ sourceKinds: ['human', 'programmatic', 'consensus', 'model', 'external'],
603
+ nextAction: !availableGroundTruth
604
+ ? 'Initialize Ground Truth with harbor_ground_truth_init, then add versioned cases.'
605
+ : !cases.length
606
+ ? 'Add cases with artifact_ref and ternary criterion labels before collecting observations.'
607
+ : !availableReport
608
+ ? 'Collect repeated evaluator observations and run harbor_evaluator_meta_evaluate.'
609
+ : 'Review disagreements before adopting the evaluator and establishing a fresh Agent baseline.',
610
+ },
611
+ }
612
+ }
613
+
614
+ function compareTrialMaps(summary) {
615
+ return new Map((summary?.trials ?? []).map(item => [String(item.datasetTrial ?? item.name ?? item.id), normalizeTrial(item, 0)]))
616
+ }
617
+
618
+ export async function readComparison(config, args) {
619
+ const baselineJob = safeSegment(args.baseline, 'baseline')
620
+ const candidateJob = safeSegment(args.candidate, 'candidate')
621
+ const [baseline, candidate, baselineContract, candidateContract] = await Promise.all([
622
+ readJson(path.join(jobDirectory(config, baselineJob), SUMMARY_NAME)),
623
+ readJson(path.join(jobDirectory(config, candidateJob), SUMMARY_NAME)),
624
+ readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-contract.json')),
625
+ readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-contract.json')),
626
+ ])
627
+ if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
628
+ const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
629
+ const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
630
+ const reasons = []
631
+ if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
632
+ if (!baselineContext?.digest || baselineContext.digest !== candidateContext?.digest) reasons.push('Evaluation Context differs; establish a fresh baseline')
633
+ if (baselineContract?.contract_id !== candidateContract?.contract_id || baselineContract?.version !== candidateContract?.version) reasons.push('Evaluation Contract identity differs')
634
+ const directions = Object.fromEntries((candidateContract?.metrics ?? []).map(item => [item.id, item.direction ?? 'maximize']))
635
+ const metrics = Object.fromEntries([...new Set([...Object.keys(baseline.metrics ?? {}), ...Object.keys(candidate.metrics ?? {})])].map(key => [key, {
636
+ baseline: baseline.metrics?.[key], candidate: candidate.metrics?.[key],
637
+ delta: typeof baseline.metrics?.[key] === 'number' && typeof candidate.metrics?.[key] === 'number' ? candidate.metrics[key] - baseline.metrics[key] : undefined,
638
+ direction: directions[key] ?? 'maximize',
639
+ improvement: typeof baseline.metrics?.[key] === 'number' && typeof candidate.metrics?.[key] === 'number'
640
+ ? (directions[key] === 'minimize' ? baseline.metrics[key] - candidate.metrics[key] : candidate.metrics[key] - baseline.metrics[key])
641
+ : undefined,
642
+ }]))
643
+ const oldTrials = compareTrialMaps(baseline)
644
+ const nextTrials = compareTrialMaps(candidate)
645
+ const improved = []
646
+ const regressed = []
647
+ const primaryDirection = directions[candidateContract?.primary_metric] ?? 'maximize'
648
+ for (const trial of [...oldTrials.keys()].filter(key => nextTrials.has(key)).sort()) {
649
+ const oldValue = oldTrials.get(trial).score?.value ?? oldTrials.get(trial).rewards?.reward
650
+ const newValue = nextTrials.get(trial).score?.value ?? nextTrials.get(trial).rewards?.reward
651
+ if (typeof oldValue !== 'number' || typeof newValue !== 'number' || oldValue === newValue) continue
652
+ const item = { trial, baseline: oldValue, candidate: newValue, delta: newValue - oldValue }
653
+ const isImproved = primaryDirection === 'minimize' ? newValue < oldValue : newValue > oldValue
654
+ ;(isImproved ? improved : regressed).push(item)
655
+ }
656
+ const baselineExceptions = new Set((baseline.exceptions ?? []).map(item => String(item.trial)))
657
+ const newExceptions = (candidate.exceptions ?? []).filter(item => !baselineExceptions.has(String(item.trial)))
658
+ const artifactRegressions = (baseline.artifact_validation?.valid && !candidate.artifact_validation?.valid) ? ['artifact-validation'] : []
659
+ return {
660
+ schemaVersion: 1, baselineJob, candidateJob, comparable: reasons.length === 0, comparabilityReasons: reasons,
661
+ metrics, population: { baseline: baseline.n_trials, candidate: candidate.n_trials, baselineValid: baseline.n_valid_scores, candidateValid: candidate.n_valid_scores },
662
+ improvedTrials: improved, regressedTrials: regressed, newExceptions, artifactRegressions,
663
+ gateEligibility: reasons.length ? 'not-comparable' : 'requires-explicit-gate',
664
+ note: 'This read-only comparison never runs Gate, promotes a Candidate, deploys, or publishes.',
665
+ }
666
+ }
667
+
668
+ export async function readEvaluatorGovernance(config, args) {
669
+ const job = safeSegment(args.job, 'job')
670
+ const directory = jobDirectory(config, job)
671
+ const [stack, contract, context] = await Promise.all([
672
+ readJson(path.join(directory, 'evaluation-stack-manifest.json')),
673
+ readJson(path.join(directory, 'evaluation-contract.json')),
674
+ readJson(path.join(directory, 'evaluation-context.json')),
675
+ ])
676
+ if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
677
+ const components = {}
678
+ for (const [role, component] of Object.entries(stack.components ?? {})) {
679
+ const entry = component?.entry
680
+ const source = entry ? await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot) : { error: 'entry unavailable' }
681
+ components[role] = { ...component, source }
682
+ }
683
+ let comparison
684
+ if (args.compareJob) {
685
+ const other = await readJson(path.join(jobDirectory(config, safeSegment(args.compareJob, 'compareJob')), 'evaluation-stack-manifest.json'))
686
+ const changes = []
687
+ for (const role of new Set([...Object.keys(stack.components ?? {}), ...Object.keys(other?.components ?? {})])) {
688
+ const before = other?.components?.[role]
689
+ const after = stack.components?.[role]
690
+ if (before?.digest !== after?.digest || before?.version !== after?.version) changes.push({ role, before, after, rewardAffecting: Boolean(before?.reward_affecting || after?.reward_affecting) })
691
+ }
692
+ comparison = {
693
+ changes,
694
+ freshBaselineRequired: changes.some(item => item.rewardAffecting) || JSON.stringify(other?.judge) !== JSON.stringify(stack.judge),
695
+ createsNewIdentity: true,
696
+ overwritesHistoricalIdentity: false,
697
+ }
698
+ }
699
+ return {
700
+ schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest },
701
+ judge: stack.judge, contract, contextDigest: context?.digest, components, comparison,
702
+ editingPolicy: {
703
+ browserWriteEnabled: false,
704
+ saveBehavior: 'Create a new Stack/component identity in source control, then run a fresh baseline when reward-affecting semantics change.',
705
+ automaticEvaluation: false, automaticGate: false,
706
+ },
707
+ upgradeWorkflow: {
708
+ steps: [
709
+ 'Inspect the current Evaluator, Rubric, Judge, Contract, and representative false-positive/false-negative Trials.',
710
+ 'Create a new Evaluator/Rubric/Judge identity and source file; never overwrite the historical identity.',
711
+ 'Run meta-evaluation against independently maintained, provenance-bearing GT and report ESF, SCE, RCR, latency, and cost as applicable.',
712
+ 'Update Evaluation Stack identity and preview Context v2 impact.',
713
+ 'Establish a fresh Agent baseline before comparing Agent Candidates under the new reward semantics.',
714
+ ],
715
+ freshBaselineRequiredWhen: ['evaluator digest changes', 'rubric digest changes', 'judge identity or parameters change'],
716
+ automaticActions: [],
717
+ skillPrompt: 'Use evolve-agent-with-harbor to upgrade this evaluator. First inspect governance evidence, clarify GT source type, provenance, ownership, and target meta-metrics, then propose a new immutable evaluator identity and fresh-baseline plan. Do not edit or run anything until I approve the controlled change.',
718
+ },
719
+ }
266
720
  }