dsh-harbor-evolution 0.4.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
@@ -1,15 +1,45 @@
1
- import { access, constants, readdir, readFile, stat } from 'node:fs/promises'
1
+ import { access, constants, lstat, readdir, readFile, stat } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  import { resolveWithin } from './evolution.js'
5
5
 
6
6
  const SUMMARY_NAME = 'evaluation-summary.json'
7
- const PROMOTION_NAME = 'promotion-report.json'
8
7
  const MAX_JOBS = 50
8
+ const MAX_JSON_BYTES = 2 * 1024 * 1024
9
+ const MAX_SOURCE_BYTES = 128 * 1024
10
+ const MAX_PREVIEW_BYTES = 512 * 1024
11
+ const MAX_TRIAL_LIMIT = 100
12
+ const jsonCache = new Map()
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
9
15
 
10
- async function readJson(file) {
16
+ function safeSegment(value, label) {
17
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(value ?? ''))) throw new Error(`${label} is invalid`)
18
+ return String(value)
19
+ }
20
+
21
+ function redact(value, depth = 0, maxText = 8_000) {
22
+ if (depth > 10) return '[TRUNCATED depth]'
23
+ if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1, maxText))
24
+ if (value && typeof value === 'object') {
25
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
26
+ }
27
+ if (typeof value === 'string' && value.length > maxText) return `${value.slice(0, maxText)}\n[TRUNCATED ${value.length - maxText} chars]`
28
+ return value
29
+ }
30
+
31
+ async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000 } = {}) {
11
32
  try {
12
- return JSON.parse(await readFile(file, 'utf8'))
33
+ const details = await lstat(file)
34
+ if (details.isSymbolicLink()) return { __readError: `${path.basename(file)} may not be a symlink` }
35
+ if (!details.isFile()) return { __readError: `${path.basename(file)} is not a file` }
36
+ if (details.size > maxBytes) return { __readError: `${path.basename(file)} exceeds ${maxBytes} bytes` }
37
+ const cached = jsonCache.get(file)
38
+ const identity = `${details.mtimeMs}:${details.size}:${maxText}`
39
+ if (cached?.identity === identity) return cached.value
40
+ const value = redact(JSON.parse(await readFile(file, 'utf8')), 0, maxText)
41
+ jsonCache.set(file, { identity, value })
42
+ return value
13
43
  } catch (error) {
14
44
  if (error.code === 'ENOENT') return undefined
15
45
  if (error instanceof SyntaxError) return { __readError: `invalid JSON in ${path.basename(file)}` }
@@ -17,59 +47,148 @@ async function readJson(file) {
17
47
  }
18
48
  }
19
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
+
20
65
  async function directoryCheck(directory, { optional = false } = {}) {
21
66
  try {
22
- const details = await stat(directory)
23
- if (!details.isDirectory()) return { status: 'error', detail: 'not a directory' }
67
+ const details = await lstat(directory)
68
+ if (details.isSymbolicLink() || !details.isDirectory()) return { status: 'error', detail: 'not a safe directory' }
24
69
  await access(directory, constants.R_OK)
25
70
  return { status: 'ok', detail: 'readable' }
26
71
  } catch (error) {
27
72
  if (optional && error.code === 'ENOENT') return { status: 'warning', detail: 'not created yet' }
28
- return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : error.message }
73
+ return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : 'not readable' }
74
+ }
75
+ }
76
+
77
+ async function fileCheck(file) {
78
+ try {
79
+ const details = await lstat(file)
80
+ return details.isFile() && !details.isSymbolicLink()
81
+ ? { status: 'ok', detail: path.basename(file) }
82
+ : { status: 'error', detail: 'not a safe file' }
83
+ } catch (error) {
84
+ return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : 'not readable' }
29
85
  }
30
86
  }
31
87
 
32
88
  async function executableCheck(command) {
33
89
  if (!command) return { status: 'error', detail: 'not configured' }
34
- if (!path.isAbsolute(command)) return { status: 'ok', detail: `${command} (resolved from PATH)` }
90
+ if (!path.isAbsolute(command)) return { status: 'ok', detail: `${command} (PATH)` }
35
91
  try {
36
92
  await access(command, constants.X_OK)
37
- return { status: 'ok', detail: command }
93
+ return { status: 'ok', detail: path.basename(command) }
38
94
  } catch (error) {
39
- return { status: 'error', detail: error.code === 'ENOENT' ? `${command} not found` : error.message }
95
+ return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : 'not executable' }
40
96
  }
41
97
  }
42
98
 
43
- function jobStatus(summary) {
44
- if (!summary) return 'pending'
45
- if (summary.__readError) return 'failed'
46
- const trials = Number(summary.n_trials ?? 0)
47
- const exceptions = Number(summary.n_exceptions ?? 0)
48
- if (trials > 0 && exceptions >= trials) return 'failed'
49
- if (exceptions > 0) return 'partial'
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
+ }
117
+ }
118
+
119
+ function primaryMetric(summary, contract) {
120
+ const name = contract?.primary_metric
121
+ if (name && typeof summary?.metrics?.[name] === 'number') return { name, value: summary.metrics[name] }
122
+ const entry = Object.entries(summary?.metrics ?? {}).find(([, value]) => typeof value === 'number')
123
+ return entry ? { name: entry[0], value: entry[1] } : undefined
124
+ }
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'
50
154
  return 'completed'
51
155
  }
52
156
 
53
157
  async function readJob(jobsDir, entry, details) {
54
158
  const directory = path.join(jobsDir, entry.name)
55
- const summary = await readJson(path.join(directory, SUMMARY_NAME))
56
- const promotion = await readJson(path.join(directory, PROMOTION_NAME))
159
+ const [summary, contextFile, promotion, contract, lifecycle, registry, stack] = await Promise.all([
160
+ readJson(path.join(directory, SUMMARY_NAME)),
161
+ readJson(path.join(directory, 'evaluation-context.json')),
162
+ readJson(path.join(directory, 'promotion-report.json')),
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')),
167
+ ])
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)
57
173
  return {
58
174
  name: entry.name,
59
- path: directory,
60
- updatedAt: details.mtime.toISOString(),
61
- status: jobStatus(summary),
62
- 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,
63
182
  nExceptions: Number(summary?.n_exceptions ?? 0),
183
+ primaryMetric: primaryMetric(summary, contract),
64
184
  metrics: summary?.metrics ?? {},
65
- candidate: summary?.candidate ?? undefined,
66
- evaluationContext: summary?.evaluation_context ?? undefined,
67
- promotion: promotion ? {
68
- decision: promotion.decision,
69
- reasons: Array.isArray(promotion.reasons) ? promotion.reasons : [],
70
- baselineJob: promotion.baseline_job,
71
- candidateJob: promotion.candidate_job,
72
- } : undefined,
185
+ candidate: summary?.candidate ?? evaluationContext?.candidate,
186
+ dataset: evaluationContext?.dataset,
187
+ evaluationContext,
188
+ progress,
189
+ capabilities,
190
+ artifactValidation: summary?.artifact_validation,
191
+ promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job } : undefined,
73
192
  readError: summary?.__readError,
74
193
  }
75
194
  }
@@ -82,62 +201,520 @@ async function listJobs(jobsDir) {
82
201
  if (error.code === 'ENOENT') return []
83
202
  throw error
84
203
  }
85
- const recent = await Promise.all(entries
86
- .filter(entry => entry.isDirectory())
87
- .map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
204
+ const directories = entries.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
205
+ const recent = await Promise.all(directories.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
88
206
  recent.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs)
89
- return Promise.all(recent
90
- .slice(0, MAX_JOBS)
91
- .map(({ entry, details }) => readJob(jobsDir, entry, details)))
207
+ const jobs = await Promise.all(recent.map(({ entry, details }) => readJob(jobsDir, entry, details)))
208
+ return jobs.filter(Boolean).slice(0, MAX_JOBS)
92
209
  }
93
210
 
94
- function latestMetric(jobs) {
95
- const completed = jobs.find(job => job.status === 'completed' || job.status === 'partial')
96
- if (!completed) return undefined
97
- const entry = Object.entries(completed.metrics).find(([, value]) => typeof value === 'number')
98
- return entry ? { name: entry[0], value: entry[1] } : undefined
211
+ function jobsDirectory(config) {
212
+ return resolveWithin(path.resolve(config.projectRoot), config.jobsDir, 'jobsDir')
213
+ }
214
+
215
+ function jobDirectory(config, job) {
216
+ return path.join(jobsDirectory(config), safeSegment(job, 'job'))
99
217
  }
100
218
 
101
219
  export async function readDashboardSnapshot(config, metadata = {}) {
102
220
  const projectRoot = path.resolve(config.projectRoot)
103
- const jobsDir = resolveWithin(projectRoot, config.jobsDir, 'jobsDir')
104
- const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck] = await Promise.all([
221
+ const jobsDir = jobsDirectory(config)
222
+ const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
105
223
  listJobs(jobsDir),
106
224
  directoryCheck(projectRoot),
107
225
  directoryCheck(jobsDir, { optional: true }),
108
226
  executableCheck(config.harborBin),
109
227
  executableCheck(config.harborDshBin),
228
+ fileCheck(path.join(projectRoot, '.harbor', 'evaluation-stack.yml')),
110
229
  ])
111
-
112
- const counts = jobs.reduce((result, job) => {
113
- result[job.status] = (result[job.status] ?? 0) + 1
114
- return result
115
- }, {})
116
-
230
+ const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
231
+ const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
117
232
  return {
118
- schemaVersion: 1,
233
+ schemaVersion: 3,
119
234
  generatedAt: new Date().toISOString(),
120
235
  pluginVersion: metadata.pluginVersion ?? 'development',
121
- config: {
122
- projectRoot,
123
- jobsDir,
124
- dshVersion: config.dshVersion,
125
- agentImportPath: config.agentImportPath,
126
- pluginImportPath: config.pluginImportPath,
127
- },
128
- checks: {
129
- projectRoot: projectRootCheck,
130
- jobsDir: jobsDirCheck,
131
- harbor: harborCheck,
132
- harborDsh: harborDshCheck,
133
- },
236
+ config: { jobsDir: config.jobsDir, dshVersion: config.dshVersion, agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
237
+ checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
134
238
  overview: {
135
239
  totalJobs: jobs.length,
136
- completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0),
137
- activeJobs: counts.pending ?? 0,
240
+ completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
241
+ activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
138
242
  failedJobs: counts.failed ?? 0,
139
- latestMetric: latestMetric(jobs),
243
+ latestMetric,
140
244
  },
141
245
  jobs,
142
246
  }
143
247
  }
248
+
249
+ const DETAIL_ARTIFACTS = {
250
+ summary: 'evaluation-summary.json',
251
+ candidate: 'candidate-manifest.json',
252
+ dataset: 'dataset-manifest.json',
253
+ datasetPreview: 'dataset-preview.json',
254
+ stack: 'evaluation-stack-manifest.json',
255
+ context: 'evaluation-context.json',
256
+ contract: 'evaluation-contract.json',
257
+ doctor: 'architecture-doctor.json',
258
+ population: 'population-report.json',
259
+ lifecycle: 'trial-lifecycle.json',
260
+ registry: 'artifact-registry.json',
261
+ diagnosis: 'diagnosis-report.json',
262
+ optimization: 'optimization-report.json',
263
+ promotion: 'promotion-report.json',
264
+ }
265
+
266
+ function schemaIssue(key, value) {
267
+ if (value === undefined) return undefined
268
+ if (value?.__readError) return value.__readError
269
+ if (!isObject(value)) return 'artifact must be an object'
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(', ')}`
275
+ const required = {
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'],
280
+ }[key] ?? []
281
+ const missing = required.filter(field => value[field] === undefined)
282
+ return missing.length ? `missing fields: ${missing.join(', ')}` : undefined
283
+ }
284
+
285
+ export async function readJobDetail(config, args) {
286
+ const job = safeSegment(args.job, 'job')
287
+ const directory = jobDirectory(config, job)
288
+ const check = await directoryCheck(directory)
289
+ if (check.status !== 'ok') throw new Error('Job not found')
290
+ const values = await Promise.all(Object.values(DETAIL_ARTIFACTS).map(name => readJson(path.join(directory, name))))
291
+ const artifacts = Object.fromEntries(Object.keys(DETAIL_ARTIFACTS).map((key, index) => [key, values[index]]))
292
+ if (artifacts.summary && !artifacts.summary.__readError) {
293
+ const { trials: _trials, ...lightSummary } = artifacts.summary
294
+ artifacts.summary = lightSummary
295
+ }
296
+ const validation = Object.fromEntries(Object.entries(artifacts).map(([key, value]) => {
297
+ const issue = schemaIssue(key, value)
298
+ return [key, value === undefined ? { status: 'unavailable', reason: 'capability-not-produced' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
299
+ }))
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
+ })
397
+ }
398
+
399
+ export async function readTrialsPage(config, args) {
400
+ const job = safeSegment(args.job, 'job')
401
+ const offset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
402
+ const limit = Math.min(MAX_TRIAL_LIMIT, Math.max(1, Number.parseInt(args.limit ?? 50, 10) || 50))
403
+ const query = String(args.query ?? '').trim().toLowerCase()
404
+ const status = String(args.status ?? '')
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
+ }
423
+ }
424
+
425
+ function assessmentName(id) {
426
+ return `${String(id).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '') || 'trial'}.json`
427
+ }
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
+
534
+ export async function readTrialDetail(config, args) {
535
+ const job = safeSegment(args.job, 'job')
536
+ const trial = safeSegment(args.trial, 'trial')
537
+ const directory = jobDirectory(config, job)
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
+ }
720
+ }