dsh-harbor-evolution 0.8.2 → 0.9.2

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
@@ -2,7 +2,14 @@ import { access, constants, lstat, readdir, readFile, stat } from 'node:fs/promi
2
2
  import { createHash } from 'node:crypto'
3
3
  import path from 'node:path'
4
4
 
5
+ import {
6
+ isSensitiveCredentialContainerKey,
7
+ redactCredentialText,
8
+ redactLocalPaths,
9
+ redactOpaqueSecretText,
10
+ } from './credential-redaction.js'
5
11
  import { resolveWithin } from './evolution.js'
12
+ import { ATTENTION_FILTERS, attentionCounts, jobAttention, matchesJobFilter } from './workbench-health.js'
6
13
 
7
14
  const SUMMARY_NAME = 'evaluation-summary.json'
8
15
  const HISTORICAL_COMPLETION_NAME = 'historical-evaluation-complete.json'
@@ -13,8 +20,7 @@ const MAX_SOURCE_BYTES = 128 * 1024
13
20
  const MAX_PREVIEW_BYTES = 512 * 1024
14
21
  const MAX_TRIAL_LIMIT = 100
15
22
  const jsonCache = new Map()
16
- const SENSITIVE_KEY = /authorization|cookie|token|api[_-]?key|secret|password|request[_-]?headers/i
17
- const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
23
+ const authoritativeRevisions = new WeakMap()
18
24
  const WORKSPACE_SKIP_DIRECTORIES = new Set([
19
25
  '.cache', '.git', '.harbor', '.next', '.venv', '__pycache__',
20
26
  'build', 'candidates', 'coverage', 'datasets', 'dist', 'jobs', 'node_modules', 'public', 'vendor', 'venv',
@@ -30,26 +36,83 @@ function redact(value, depth = 0, maxText = 8_000) {
30
36
  if (depth > 10) return '[TRUNCATED depth]'
31
37
  if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1, maxText))
32
38
  if (value && typeof value === 'object') {
33
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
39
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, isSensitiveCredentialContainerKey(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
40
+ }
41
+ if (typeof value === 'string') {
42
+ const safe = redactSourceText(value)
43
+ return safe.length > maxText ? `${safe.slice(0, maxText)}\n[TRUNCATED ${safe.length - maxText} chars]` : safe
34
44
  }
35
- if (typeof value === 'string' && value.length > maxText) return `${value.slice(0, maxText)}\n[TRUNCATED ${value.length - maxText} chars]`
36
45
  return value
37
46
  }
38
47
 
39
- async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000 } = {}) {
48
+ function redactSourceText(value) {
49
+ return redactOpaqueSecretText(redactCredentialText(value))
50
+ }
51
+
52
+ function rawContentRevision(value) {
53
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`
54
+ }
55
+
56
+ function rememberAuthoritativeRevision(value, ...sources) {
57
+ if (!value || typeof value !== 'object') return value
58
+ const revisions = sources
59
+ .map(source => typeof source === 'string' ? source : authoritativeRevisions.get(source))
60
+ .filter(Boolean)
61
+ if (revisions.length) authoritativeRevisions.set(value, rawContentRevision(JSON.stringify(revisions)))
62
+ return value
63
+ }
64
+
65
+ /** Return a non-serialized digest of the full artifact bytes used to build a bounded dashboard value. */
66
+ export function authoritativeArtifactRevision(value) {
67
+ return value && typeof value === 'object' ? authoritativeRevisions.get(value) : undefined
68
+ }
69
+
70
+ async function safePathDetails(target, root) {
71
+ const resolvedTarget = path.resolve(target)
72
+ if (!root) return lstat(resolvedTarget)
73
+ const resolvedRoot = path.resolve(root)
74
+ const relative = path.relative(resolvedRoot, resolvedTarget)
75
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
76
+ const error = new Error('path is outside trusted project root')
77
+ error.code = 'HARBOR_UNSAFE_PATH'
78
+ throw error
79
+ }
80
+ let current = resolvedRoot
81
+ let details = await lstat(current)
82
+ if (details.isSymbolicLink()) {
83
+ const error = new Error('trusted project root may not be a symlink')
84
+ error.code = 'HARBOR_UNSAFE_PATH'
85
+ throw error
86
+ }
87
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
88
+ current = path.join(current, segment)
89
+ details = await lstat(current)
90
+ if (details.isSymbolicLink()) {
91
+ const error = new Error('symlinked path component is not allowed')
92
+ error.code = 'HARBOR_UNSAFE_PATH'
93
+ throw error
94
+ }
95
+ }
96
+ return details
97
+ }
98
+
99
+ async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000, root } = {}) {
40
100
  try {
41
- const details = await lstat(file)
101
+ const details = await safePathDetails(file, root)
42
102
  if (details.isSymbolicLink()) return { __readError: `${path.basename(file)} may not be a symlink` }
43
103
  if (!details.isFile()) return { __readError: `${path.basename(file)} is not a file` }
44
104
  if (details.size > maxBytes) return { __readError: `${path.basename(file)} exceeds ${maxBytes} bytes` }
45
105
  const cached = jsonCache.get(file)
46
- const identity = `${details.mtimeMs}:${details.size}:${maxText}`
106
+ const identity = `${details.dev}:${details.ino}:${details.ctimeMs}:${details.mtimeMs}:${details.size}:${maxText}`
47
107
  if (cached?.identity === identity) return cached.value
48
- const value = redact(JSON.parse(await readFile(file, 'utf8')), 0, maxText)
108
+ const source = await readFile(file, 'utf8')
109
+ const value = redact(JSON.parse(source), 0, maxText)
110
+ if (value && typeof value === 'object') authoritativeRevisions.set(value, rawContentRevision(source))
49
111
  jsonCache.set(file, { identity, value })
50
112
  return value
51
113
  } catch (error) {
52
114
  if (error.code === 'ENOENT') return undefined
115
+ if (error.code === 'HARBOR_UNSAFE_PATH') return { __readError: `${path.basename(file)} is not a safe file` }
53
116
  if (error instanceof SyntaxError) return { __readError: `invalid JSON in ${path.basename(file)}` }
54
117
  throw error
55
118
  }
@@ -60,19 +123,19 @@ async function readSafeText(file, projectRoot) {
60
123
  const root = path.resolve(projectRoot)
61
124
  if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return { error: 'source is outside projectRoot' }
62
125
  try {
63
- const details = await lstat(resolved)
126
+ const details = await safePathDetails(resolved, root)
64
127
  if (!details.isFile() || details.isSymbolicLink()) return { error: 'source is not a safe file' }
65
128
  if (details.size > MAX_SOURCE_BYTES) return { error: `source exceeds ${MAX_SOURCE_BYTES} bytes` }
66
- const text = (await readFile(resolved, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
129
+ const text = redactLocalPaths(redactSourceText(await readFile(resolved, 'utf8')))
67
130
  return { text: redact(text) }
68
131
  } catch (error) {
69
132
  return { error: error.code === 'ENOENT' ? 'source is unavailable' : 'source is unreadable' }
70
133
  }
71
134
  }
72
135
 
73
- async function directoryCheck(directory, { optional = false } = {}) {
136
+ async function directoryCheck(directory, { optional = false, root } = {}) {
74
137
  try {
75
- const details = await lstat(directory)
138
+ const details = await safePathDetails(directory, root)
76
139
  if (details.isSymbolicLink() || !details.isDirectory()) return { status: 'error', detail: 'not a safe directory' }
77
140
  await access(directory, constants.R_OK)
78
141
  return { status: 'ok', detail: 'readable' }
@@ -82,9 +145,9 @@ async function directoryCheck(directory, { optional = false } = {}) {
82
145
  }
83
146
  }
84
147
 
85
- async function fileCheck(file) {
148
+ async function fileCheck(file, { root } = {}) {
86
149
  try {
87
- const details = await lstat(file)
150
+ const details = await safePathDetails(file, root)
88
151
  return details.isFile() && !details.isSymbolicLink()
89
152
  ? { status: 'ok', detail: path.basename(file) }
90
153
  : { status: 'error', detail: 'not a safe file' }
@@ -242,17 +305,17 @@ function jobStatus(summary, lifecycle, progress, jobKind, completion, jobName) {
242
305
  return 'completed'
243
306
  }
244
307
 
245
- async function readJob(jobsDir, entry, details) {
308
+ async function readJob(jobsDir, entry, details, projectRoot) {
246
309
  const directory = path.join(jobsDir, entry.name)
247
310
  const [summary, contextFile, promotion, contract, lifecycle, registry, stack, completion] = await Promise.all([
248
- readJson(path.join(directory, SUMMARY_NAME)),
249
- readJson(path.join(directory, 'evaluation-context.json')),
250
- readJson(path.join(directory, 'promotion-report.json')),
251
- readJson(path.join(directory, 'evaluation-contract.json')),
252
- readJson(path.join(directory, 'trial-lifecycle.json')),
253
- readJson(path.join(directory, 'artifact-registry.json')),
254
- readJson(path.join(directory, 'evaluation-stack-manifest.json')),
255
- readJson(path.join(directory, HISTORICAL_COMPLETION_NAME)),
311
+ readJson(path.join(directory, SUMMARY_NAME), { root: projectRoot }),
312
+ readJson(path.join(directory, 'evaluation-context.json'), { root: projectRoot }),
313
+ readJson(path.join(directory, 'promotion-report.json'), { root: projectRoot }),
314
+ readJson(path.join(directory, 'evaluation-contract.json'), { root: projectRoot }),
315
+ readJson(path.join(directory, 'trial-lifecycle.json'), { root: projectRoot }),
316
+ readJson(path.join(directory, 'artifact-registry.json'), { root: projectRoot }),
317
+ readJson(path.join(directory, 'evaluation-stack-manifest.json'), { root: projectRoot }),
318
+ readJson(path.join(directory, HISTORICAL_COMPLETION_NAME), { root: projectRoot }),
256
319
  ])
257
320
  const evaluationContext = summary?.evaluation_context ?? contextFile
258
321
  if (!evaluationContext && !summary && !lifecycle) return undefined
@@ -276,6 +339,8 @@ async function readJob(jobsDir, entry, details) {
276
339
  nInvalidScores: summary?.n_invalid_scores,
277
340
  nUnscoredTrials: Number(coverage.unscored_trials ?? 0),
278
341
  nExceptions: Number(summary?.n_exceptions ?? 0),
342
+ nInfrastructureExceptions: Number(summary?.n_infrastructure_exceptions ?? 0),
343
+ nEvaluationExceptions: Number(summary?.n_evaluation_exceptions ?? 0),
279
344
  primaryMetric: primaryMetric(summary, contract),
280
345
  metrics: summary?.metrics ?? {},
281
346
  candidate: summary?.candidate ?? evaluationContext?.candidate,
@@ -289,12 +354,16 @@ async function readJob(jobsDir, entry, details) {
289
354
  progress,
290
355
  capabilities,
291
356
  artifactValidation: summary?.artifact_validation,
292
- promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job } : undefined,
357
+ promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job, regressions: promotion.regressed_trials?.length ?? 0 } : undefined,
293
358
  readError: summary?.__readError,
294
359
  }
295
360
  }
296
361
 
297
- async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE } = {}) {
362
+ async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE, root, attention = 'all' } = {}) {
363
+ if (!ATTENTION_FILTERS.includes(attention)) throw new Error('HARBOR_FILTER_INVALID: Unknown attention filter')
364
+ const check = await directoryCheck(jobsDir, { optional: true, root })
365
+ if (check.status === 'warning') return { items: [], total: 0, offset, limit, hasMore: false }
366
+ if (check.status !== 'ok') throw new Error('Jobs directory is not safe')
298
367
  let entries
299
368
  try {
300
369
  entries = await readdir(jobsDir, { withFileTypes: true })
@@ -305,9 +374,18 @@ async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE } =
305
374
  const directories = entries.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
306
375
  const recent = await Promise.all(directories.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
307
376
  recent.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs)
308
- const page = recent.slice(offset, offset + limit)
309
- const jobs = await Promise.all(page.map(({ entry, details }) => readJob(jobsDir, entry, details)))
310
- return { items: jobs.filter(Boolean), total: recent.length, offset, limit, hasMore: offset + limit < recent.length }
377
+ const allJobs = []
378
+ let cursor = 0
379
+ await Promise.all(Array.from({ length: Math.min(8, recent.length) }, async () => {
380
+ while (cursor < recent.length) {
381
+ const { entry, details } = recent[cursor++]
382
+ const job = await readJob(jobsDir, entry, details, root)
383
+ if (job) allJobs.push(job)
384
+ }
385
+ }))
386
+ allJobs.sort((a, b) => jobAttention(a).rank - jobAttention(b).rank || Date.parse(b.updatedAt) - Date.parse(a.updatedAt) || a.name.localeCompare(b.name))
387
+ const jobs = allJobs.filter(job => matchesJobFilter(job, attention))
388
+ return { items: jobs.slice(offset, offset + limit), allJobs, attentionCounts: attentionCounts(allJobs), total: jobs.length, offset, limit, hasMore: offset + limit < jobs.length }
311
389
  }
312
390
 
313
391
  function relativePath(root, value) {
@@ -320,9 +398,9 @@ function workspaceIdentity(projectRoot, workspaceRoot, jobsDir, preferred) {
320
398
  return `${label}-${digest}`
321
399
  }
322
400
 
323
- async function regularFile(pathname) {
401
+ async function regularFile(pathname, root) {
324
402
  try {
325
- const details = await lstat(pathname)
403
+ const details = await safePathDetails(pathname, root)
326
404
  return details.isFile() && !details.isSymbolicLink()
327
405
  } catch (error) {
328
406
  if (error.code === 'ENOENT') return false
@@ -338,7 +416,7 @@ export async function discoverWorkspaceConfigs(config) {
338
416
  const harborDirectory = path.join(directory, '.harbor')
339
417
  const descriptorPath = path.join(harborDirectory, 'workspace.json')
340
418
  const stackPath = path.join(harborDirectory, 'evaluation-stack.yml')
341
- const descriptor = await readJson(descriptorPath)
419
+ const descriptor = await readJson(descriptorPath, { root: projectRoot })
342
420
  if (descriptor?.schema_version === 1 && descriptor.jobs && descriptor.stack) {
343
421
  const jobs = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.jobs), 'workspace.jobs'))
344
422
  const stack = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.stack), 'workspace.stack'))
@@ -351,7 +429,7 @@ export async function discoverWorkspaceConfigs(config) {
351
429
  workspaceLabel: descriptor.workspace_id ?? workspaceRoot,
352
430
  workspaceId: workspaceIdentity(projectRoot, directory, jobs, descriptor.workspace_id),
353
431
  })
354
- } else if (await regularFile(stackPath)) {
432
+ } else if (await regularFile(stackPath, projectRoot)) {
355
433
  const workspaceRoot = relativePath(projectRoot, directory)
356
434
  const jobs = relativePath(projectRoot, path.join(directory, 'jobs'))
357
435
  found.push({
@@ -394,21 +472,32 @@ function jobDirectory(config, job) {
394
472
  return path.join(jobsDirectory(config), safeSegment(job, 'job'))
395
473
  }
396
474
 
475
+ /** Read the stable Job Summary through the same bounded, redacting reader used by the Workbench. */
476
+ export async function readEvaluationSummary(config, args) {
477
+ const projectRoot = path.resolve(config.projectRoot)
478
+ const directory = resolveWithin(projectRoot, args.jobPath, 'jobPath')
479
+ const check = await directoryCheck(directory, { root: projectRoot })
480
+ if (check.status !== 'ok') return { __readError: 'Job path is not a safe directory' }
481
+ const summary = await readJson(path.join(directory, SUMMARY_NAME), { root: projectRoot })
482
+ return summary ?? { __readError: `${SUMMARY_NAME} is unavailable` }
483
+ }
484
+
397
485
  export async function readDashboardSnapshot(config, metadata = {}, args = {}) {
398
486
  const projectRoot = path.resolve(config.projectRoot)
399
487
  const jobsDir = jobsDirectory(config)
400
488
  const offset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
401
489
  const limit = Math.min(MAX_JOB_PAGE_SIZE, Math.max(1, Number.parseInt(args.limit ?? DEFAULT_JOB_PAGE_SIZE, 10) || DEFAULT_JOB_PAGE_SIZE))
402
490
  const [jobPage, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
403
- listJobs(jobsDir, { offset, limit }),
491
+ listJobs(jobsDir, { offset, limit, root: projectRoot, attention: args.attention ?? 'all' }),
404
492
  directoryCheck(projectRoot),
405
- directoryCheck(jobsDir, { optional: true }),
493
+ directoryCheck(jobsDir, { optional: true, root: projectRoot }),
406
494
  executableCheck(config.harborBin),
407
495
  executableCheck(config.harborDshBin),
408
- fileCheck(resolveWithin(projectRoot, config.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath')),
496
+ fileCheck(resolveWithin(projectRoot, config.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath'), { root: projectRoot }),
409
497
  ])
410
498
  const jobs = jobPage.items
411
- const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
499
+ const allJobs = jobPage.allJobs ?? jobs
500
+ const counts = allJobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
412
501
  const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
413
502
  return {
414
503
  schemaVersion: 3,
@@ -416,10 +505,11 @@ export async function readDashboardSnapshot(config, metadata = {}, args = {}) {
416
505
  pluginVersion: metadata.pluginVersion ?? 'development',
417
506
  workspace: { id: config.workspaceId, label: config.workspaceLabel, root: config.workspaceRoot ?? '.', stackPath: config.stackPath },
418
507
  workspaces: metadata.workspaces ?? [],
419
- config: { projectRoot, projectRootSource: metadata.projectRootSource ?? 'configured', jobsDir: config.jobsDir, runtimePolicy: config.runtimePolicy ?? 'follow-latest', agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
508
+ config: { projectRoot, projectRootSource: metadata.projectRootSource ?? 'configured', jobsDir: config.jobsDir, runtimePolicy: config.runtimePolicy ?? 'candidate-locked', agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
420
509
  checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
421
510
  overview: {
422
- totalJobs: jobPage.total,
511
+ totalJobs: allJobs.length,
512
+ attention: jobPage.attentionCounts ?? attentionCounts(allJobs),
423
513
  visibleJobs: jobs.length,
424
514
  completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
425
515
  activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
@@ -476,13 +566,15 @@ function schemaIssue(key, value) {
476
566
  export async function readJobDetail(config, args) {
477
567
  const job = safeSegment(args.job, 'job')
478
568
  const directory = jobDirectory(config, job)
479
- const check = await directoryCheck(directory)
569
+ const projectRoot = path.resolve(config.projectRoot)
570
+ const check = await directoryCheck(directory, { root: projectRoot })
480
571
  if (check.status !== 'ok') throw new Error('Job not found')
481
- const values = await Promise.all(Object.values(DETAIL_ARTIFACTS).map(name => readJson(path.join(directory, name))))
572
+ const values = await Promise.all(Object.values(DETAIL_ARTIFACTS).map(name => readJson(path.join(directory, name), { root: projectRoot })))
482
573
  const artifacts = Object.fromEntries(Object.keys(DETAIL_ARTIFACTS).map((key, index) => [key, values[index]]))
483
574
  if (artifacts.summary && !artifacts.summary.__readError) {
484
- const { trials: _trials, ...lightSummary } = artifacts.summary
485
- artifacts.summary = lightSummary
575
+ const fullSummary = artifacts.summary
576
+ const { trials: _trials, ...lightSummary } = fullSummary
577
+ artifacts.summary = rememberAuthoritativeRevision(lightSummary, fullSummary)
486
578
  }
487
579
  const validation = Object.fromEntries(Object.entries(artifacts).map(([key, value]) => {
488
580
  const issue = schemaIssue(key, value)
@@ -554,9 +646,12 @@ function normalizeTrial(trial, order) {
554
646
 
555
647
  async function jobTrials(config, job) {
556
648
  const directory = jobDirectory(config, job)
649
+ const projectRoot = path.resolve(config.projectRoot)
650
+ const check = await directoryCheck(directory, { root: projectRoot })
651
+ if (check.status !== 'ok') throw new Error('Job not found')
557
652
  const [summary, lifecycle] = await Promise.all([
558
- readJson(path.join(directory, SUMMARY_NAME)),
559
- readJson(path.join(directory, 'trial-lifecycle.json')),
653
+ readJson(path.join(directory, SUMMARY_NAME), { root: projectRoot }),
654
+ readJson(path.join(directory, 'trial-lifecycle.json'), { root: projectRoot }),
560
655
  ])
561
656
  if ((!summary || summary.__readError) && (!lifecycle || lifecycle.__readError)) throw new Error('Job progress is unavailable')
562
657
  const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
@@ -625,6 +720,11 @@ export async function readTrialsPage(config, args) {
625
720
  const sort = String(args.sort ?? 'dataset-order')
626
721
  const source = await jobTrials(config, job)
627
722
  let trials = await enrichTrialsWithDataset(config, job, source.trials)
723
+ // Internal fixed-set reads must not expand to the entire (possibly large) Job.
724
+ if (Array.isArray(args.trialIds)) {
725
+ const selectedIds = new Set(args.trialIds)
726
+ trials = trials.filter(trial => selectedIds.has(trial.id))
727
+ }
628
728
  if (query) trials = trials.filter(trial => `${trial.id ?? ''} ${trial.displayName ?? ''} ${trial.name ?? ''} ${trial.datasetTrial ?? ''}`.toLowerCase().includes(query))
629
729
  if (status) trials = trials.filter(trial => trial.status === status)
630
730
  if (validity) trials = trials.filter(trial => String(Boolean(trial.score?.valid)) === validity)
@@ -656,13 +756,13 @@ function previewFromOutput(output, evidence = []) {
656
756
  return { kind: 'structured', format: 'json', title: output.title ?? 'Structured output', content: output, provenance: evidence }
657
757
  }
658
758
 
659
- async function previewFromTrialFiles(directory, lifecycle) {
759
+ async function previewFromTrialFiles(directory, lifecycle, projectRoot) {
660
760
  let trialName
661
761
  try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
662
762
  const trialDirectory = path.join(directory, trialName)
663
- const check = await directoryCheck(trialDirectory)
763
+ const check = await directoryCheck(trialDirectory, { root: projectRoot })
664
764
  if (check.status !== 'ok') return undefined
665
- const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000 })
765
+ const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000, root: projectRoot })
666
766
  const candidates = []
667
767
  for (const entry of Array.isArray(manifest) ? manifest : []) {
668
768
  if (!isObject(entry) || !['ok', 'collected', 'mounted'].includes(entry.status) || typeof entry.destination !== 'string' || !entry.destination.startsWith('artifacts/')) continue
@@ -674,31 +774,40 @@ async function previewFromTrialFiles(directory, lifecycle) {
674
774
  candidates.sort((a, b) => (priority.get(path.extname(a).toLowerCase()) ?? 99) - (priority.get(path.extname(b).toLowerCase()) ?? 99) || a.localeCompare(b))
675
775
  for (const candidate of candidates) {
676
776
  try {
677
- const details = await lstat(candidate)
777
+ const details = await safePathDetails(candidate, projectRoot)
678
778
  if (!details.isFile() || details.isSymbolicLink() || details.size > MAX_PREVIEW_BYTES) continue
679
779
  const format = path.extname(candidate).toLowerCase()
680
- const text = (await readFile(candidate, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
780
+ const source = await readFile(candidate, 'utf8')
781
+ const text = redactLocalPaths(redactSourceText(source))
681
782
  const content = format === '.json' ? redact(JSON.parse(text), 0, 128_000) : redact(text, 0, 128_000)
682
783
  const kind = ['.html', '.htm'].includes(format)
683
784
  ? 'page'
684
785
  : format === '.json' && !(isObject(content) && ['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof content[key] === 'string'))
685
786
  ? 'structured'
686
787
  : 'document'
687
- 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) }] }
788
+ return rememberAuthoritativeRevision(
789
+ { 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) }] },
790
+ rawContentRevision(source),
791
+ )
688
792
  } catch { /* try the next declared artifact */ }
689
793
  }
690
- const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000 })
794
+ const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000, root: projectRoot })
691
795
  const messages = (trajectory?.steps ?? []).filter(step => step?.source === 'agent' && typeof step.message === 'string').map(step => step.message)
692
- 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
796
+ return messages.length
797
+ ? rememberAuthoritativeRevision(
798
+ { 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' }] },
799
+ trajectory,
800
+ )
801
+ : undefined
693
802
  }
694
803
 
695
- async function evaluatorResultFromTrialFiles(directory, lifecycle) {
804
+ async function evaluatorResultFromTrialFiles(directory, lifecycle, projectRoot) {
696
805
  let trialName
697
806
  try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
698
807
  const trialDirectory = path.join(directory, trialName)
699
- const check = await directoryCheck(trialDirectory)
808
+ const check = await directoryCheck(trialDirectory, { root: projectRoot })
700
809
  if (check.status !== 'ok') return undefined
701
- const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000 })
810
+ const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000, root: projectRoot })
702
811
  return result && !result.__readError ? result : undefined
703
812
  }
704
813
 
@@ -710,19 +819,23 @@ function enrichAssessmentWithEvaluator(assessment, evaluatorResult) {
710
819
  return evaluator ? { ...item, reason: evaluator.reason ?? item.reason, recommendation: evaluator.recommendation ?? item.recommendation } : item
711
820
  })
712
821
  const evaluatorRecommendations = (evaluatorResult?.recommendations ?? []).map(item => isObject(item) ? item : { message: String(item) })
713
- return { ...assessment, criteria, recommendations: [...(assessment.recommendations ?? []), ...evaluatorRecommendations] }
822
+ return rememberAuthoritativeRevision(
823
+ { ...assessment, criteria, recommendations: [...(assessment.recommendations ?? []), ...evaluatorRecommendations] },
824
+ assessment,
825
+ evaluatorResult,
826
+ )
714
827
  }
715
828
 
716
829
  async function datasetRoots(directory, projectRoot) {
717
830
  const entries = await readdir(directory, { withFileTypes: true })
718
831
  const roots = []
719
832
  for (const entry of entries.filter(item => item.isDirectory() && !item.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
720
- const result = await readJson(path.join(directory, entry.name, 'result.json'))
833
+ const result = await readJson(path.join(directory, entry.name, 'result.json'), { root: projectRoot })
721
834
  const candidate = result?.task_id?.path
722
835
  if (typeof candidate !== 'string') continue
723
836
  try {
724
837
  const resolved = resolveWithin(projectRoot, path.relative(projectRoot, candidate), 'task path')
725
- const check = await directoryCheck(resolved)
838
+ const check = await directoryCheck(resolved, { root: projectRoot })
726
839
  if (check.status === 'ok') roots.push(resolved)
727
840
  } catch { /* ignore historical out-of-root task sources */ }
728
841
  }
@@ -732,17 +845,20 @@ async function datasetRoots(directory, projectRoot) {
732
845
  export async function readDatasetPreview(config, args) {
733
846
  const job = safeSegment(args.job, 'job')
734
847
  const directory = jobDirectory(config, job)
735
- const snapshot = await readJson(path.join(directory, 'dataset-preview.json'), { maxBytes: MAX_JSON_BYTES, maxText: 128_000 })
736
- if (snapshot && !snapshot.__readError) return { ...snapshot, source: 'job-snapshot' }
737
- const manifest = await readJson(path.join(directory, 'dataset-manifest.json'))
848
+ const projectRoot = path.resolve(config.projectRoot)
849
+ const check = await directoryCheck(directory, { root: projectRoot })
850
+ if (check.status !== 'ok') throw new Error('Job not found')
851
+ const snapshot = await readJson(path.join(directory, 'dataset-preview.json'), { maxBytes: MAX_JSON_BYTES, maxText: 128_000, root: projectRoot })
852
+ if (snapshot && !snapshot.__readError) return rememberAuthoritativeRevision({ ...snapshot, source: 'job-snapshot' }, snapshot)
853
+ const manifest = await readJson(path.join(directory, 'dataset-manifest.json'), { root: projectRoot })
738
854
  if (!manifest || manifest.__readError) throw new Error('Dataset Manifest is unavailable')
739
- const roots = await datasetRoots(directory, config.projectRoot)
855
+ const roots = await datasetRoots(directory, projectRoot)
740
856
  const tasks = []
741
857
  for (const [index, task] of (manifest.tasks ?? []).entries()) {
742
858
  const root = roots[Math.min(index, Math.max(0, roots.length - 1))]
743
859
  let instruction = { error: 'instruction source is unavailable for this historical Job' }
744
860
  if (root && typeof task?.instruction === 'string') {
745
- try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'), config.projectRoot) } catch { instruction = { error: 'instruction path is invalid' } }
861
+ try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'), projectRoot) } catch { instruction = { error: 'instruction path is invalid' } }
746
862
  }
747
863
  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')) })
748
864
  }
@@ -753,18 +869,21 @@ export async function readTrialDetail(config, args) {
753
869
  const job = safeSegment(args.job, 'job')
754
870
  const trial = safeSegment(args.trial, 'trial')
755
871
  const directory = jobDirectory(config, job)
756
- let assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)))
872
+ const projectRoot = path.resolve(config.projectRoot)
873
+ const check = await directoryCheck(directory, { root: projectRoot })
874
+ if (check.status !== 'ok') throw new Error('Job not found')
875
+ let assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)), { root: projectRoot })
757
876
  const source = await jobTrials(config, job)
758
877
  const lifecycle = source.trials.find(item => String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial)
759
878
  if ((!assessment || assessment.__readError) && lifecycle?.id && String(lifecycle.id) !== trial) {
760
- assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)))
879
+ assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)), { root: projectRoot })
761
880
  }
762
881
  if (assessment?.__readError) throw new Error('Trial assessment is invalid')
763
882
  if (!assessment && !lifecycle) throw new Error('Trial not found')
764
- assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle))
883
+ assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle, projectRoot))
765
884
  const assessmentPreview = previewFromOutput(assessment?.output, assessment?.evidence_provenance)
766
885
  const realAssessmentOutput = assessment?.evidence_provenance?.some(item => item?.kind === 'real-renderer' || item?.kind === 'agent-artifact')
767
- const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle)
886
+ const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle, projectRoot)
768
887
  const preview = realAssessmentOutput ? assessmentPreview : filePreview ?? assessmentPreview
769
888
  return {
770
889
  schemaVersion: 2, job, trial, lifecycle,
@@ -775,6 +894,116 @@ export async function readTrialDetail(config, args) {
775
894
  }
776
895
  }
777
896
 
897
+ function historicalEvidenceSelection(record, evidenceRef) {
898
+ if (evidenceRef === 'generation_record') return record
899
+ if (!evidenceRef.startsWith('generation_record.')) return undefined
900
+ const selector = evidenceRef.slice('generation_record.'.length)
901
+ const segments = selector.split('/').filter(Boolean)
902
+ if (
903
+ segments.length === 0
904
+ || segments.length > 20
905
+ || segments.some(segment => !/^(?:[A-Za-z_][A-Za-z0-9_-]{0,127}|0|[1-9][0-9]{0,5})$/.test(segment))
906
+ ) return undefined
907
+ let selected = record
908
+ for (const segment of segments) {
909
+ if (Array.isArray(selected)) {
910
+ const index = Number(segment)
911
+ if (!Number.isSafeInteger(index) || index < 0 || index >= selected.length) return undefined
912
+ selected = selected[index]
913
+ continue
914
+ }
915
+ if (!isObject(selected) || !Object.hasOwn(selected, segment)) return undefined
916
+ selected = selected[segment]
917
+ }
918
+ return selected
919
+ }
920
+
921
+ /**
922
+ * Read the two immutable evidence containers produced by Historical Generation
923
+ * evaluation. Criterion evidence refs are semantic selectors (for example,
924
+ * generation_record.visible_transcript/1), not filesystem paths. Keep the
925
+ * filesystem mapping here so neither the browser nor the Agent can choose an
926
+ * arbitrary artifact path.
927
+ */
928
+ export async function readHistoricalEvidence(config, args) {
929
+ const job = safeSegment(args.job, 'job')
930
+ const trial = safeSegment(args.trial, 'trial')
931
+ const criterion = String(args.criterion ?? '')
932
+ if (!criterion || criterion.length > 180 || /[\u0000-\u001f\u007f]/.test(criterion)) {
933
+ return { available: false, reason: 'The requested Historical Generation Criterion is invalid.' }
934
+ }
935
+ const evidenceRef = String(args.evidenceRef ?? '')
936
+ if (
937
+ evidenceRef !== 'judge-gateway'
938
+ && evidenceRef !== 'generation_record'
939
+ && !/^generation_record\.[A-Za-z_][A-Za-z0-9_-]{0,127}(?:\/(?:[A-Za-z_][A-Za-z0-9_-]{0,127}|0|[1-9][0-9]{0,5})){0,19}$/.test(evidenceRef)
940
+ ) return { available: false, reason: 'The requested ref is not a supported Historical Generation evidence selector.' }
941
+
942
+ const projectRoot = path.resolve(config.projectRoot)
943
+ const directory = jobDirectory(config, job)
944
+ const source = await jobTrials(config, job)
945
+ const lifecycle = source.trials.find(item => (
946
+ String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial
947
+ ))
948
+ if (!lifecycle) return { available: false, reason: 'The Historical Generation Trial is unavailable.' }
949
+ let trialName
950
+ try { trialName = safeSegment(lifecycle.name ?? lifecycle.id, 'trial directory') } catch {
951
+ return { available: false, reason: 'The Historical Generation Trial directory is invalid.' }
952
+ }
953
+ const trialDirectory = path.join(directory, trialName)
954
+ const trialCheck = await directoryCheck(trialDirectory, { root: projectRoot })
955
+ if (trialCheck.status !== 'ok') return { available: false, reason: 'The Historical Generation Trial directory is unavailable.' }
956
+
957
+ if (evidenceRef === 'judge-gateway') {
958
+ const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), {
959
+ maxBytes: 128_000,
960
+ maxText: 32_000,
961
+ root: projectRoot,
962
+ })
963
+ if (!result || result.__readError) return { available: false, reason: 'The frozen evaluator result is unavailable.' }
964
+ const matches = (Array.isArray(result.criteria) ? result.criteria : [])
965
+ .filter(item => isObject(item) && String(item.id) === criterion)
966
+ if (matches.length !== 1) return { available: false, reason: 'The frozen evaluator result has no unique matching Criterion.' }
967
+ return {
968
+ available: true,
969
+ content: matches[0],
970
+ source: {
971
+ id: 'evaluator-result-v2',
972
+ kind: 'evaluator-result',
973
+ artifactRef: 'verifier/evaluation-result.json',
974
+ selector: `criteria[id=${criterion}]`,
975
+ },
976
+ }
977
+ }
978
+
979
+ const observationCandidates = [
980
+ path.join(trialDirectory, 'artifacts', 'logs', 'artifacts', 'session-observation.json'),
981
+ path.join(trialDirectory, 'artifacts', 'session-observation.json'),
982
+ ]
983
+ const observations = []
984
+ for (const candidate of observationCandidates) {
985
+ const value = await readJson(candidate, { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000, root: projectRoot })
986
+ if (value && !value.__readError) {
987
+ observations.push({ value, artifactRef: path.relative(trialDirectory, candidate) })
988
+ }
989
+ }
990
+ if (observations.length === 0) return { available: false, reason: 'The frozen Session Observation is unavailable.' }
991
+ if (observations.length > 1) return { available: false, reason: 'Multiple Session Observation containers are present; exact provenance is ambiguous.' }
992
+ const [{ value: observation, artifactRef }] = observations
993
+ const content = historicalEvidenceSelection(observation, evidenceRef)
994
+ if (content === undefined) return { available: false, reason: 'The requested field is absent from the frozen Session Observation.' }
995
+ return {
996
+ available: true,
997
+ content,
998
+ source: {
999
+ id: 'frozen-session-observation',
1000
+ kind: 'historical-generation-record',
1001
+ artifactRef,
1002
+ selector: evidenceRef,
1003
+ },
1004
+ }
1005
+ }
1006
+
778
1007
  export async function readJobProgress(config, args) {
779
1008
  const job = safeSegment(args.job, 'job')
780
1009
  const since = args.since ? Date.parse(args.since) : 0
@@ -791,13 +1020,16 @@ export async function readJobProgress(config, args) {
791
1020
  }
792
1021
 
793
1022
  export async function readMetaEvaluation(config, args = {}) {
794
- const evaluationRoot = resolveWithin(config.projectRoot, args.evaluationRoot ?? '.', 'evaluationRoot')
795
- const index = await readJson(path.join(evaluationRoot, '.harbor', 'meta-artifacts.json'))
1023
+ const projectRoot = path.resolve(config.projectRoot)
1024
+ const evaluationRoot = resolveWithin(projectRoot, args.evaluationRoot ?? '.', 'evaluationRoot')
1025
+ const evaluationCheck = await directoryCheck(evaluationRoot, { root: projectRoot })
1026
+ if (evaluationCheck.status !== 'ok') throw new Error('Evaluation root is not a safe directory')
1027
+ const index = await readJson(path.join(evaluationRoot, '.harbor', 'meta-artifacts.json'), { root: projectRoot })
796
1028
  const registered = index?.schema_version === 1 ? index.artifacts ?? {} : {}
797
1029
  const groundTruthPath = resolveWithin(evaluationRoot, registered.ground_truth ?? '.harbor/ground-truth.json', 'groundTruthPath')
798
1030
  const reportPath = resolveWithin(evaluationRoot, registered.meta_evaluation_report ?? '.harbor/meta-evaluation-report.json', 'metaEvaluationReportPath')
799
- const groundTruth = await readJson(groundTruthPath, { maxText: 64_000 })
800
- const report = await readJson(reportPath, { maxText: 64_000 })
1031
+ const groundTruth = await readJson(groundTruthPath, { maxText: 64_000, root: projectRoot })
1032
+ const report = await readJson(reportPath, { maxText: 64_000, root: projectRoot })
801
1033
  const availableGroundTruth = groundTruth && !groundTruth.__readError
802
1034
  const availableReport = report && !report.__readError
803
1035
  const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
@@ -810,7 +1042,7 @@ export async function readMetaEvaluation(config, args = {}) {
810
1042
  } : undefined
811
1043
  return {
812
1044
  schemaVersion: 1,
813
- evaluationRoot: path.relative(config.projectRoot, evaluationRoot) || '.',
1045
+ evaluationRoot: path.relative(projectRoot, evaluationRoot) || '.',
814
1046
  status: availableReport ? 'evaluated' : availableGroundTruth ? (cases.length ? 'ground-truth-ready' : 'ground-truth-draft') : 'ground-truth-required',
815
1047
  groundTruth: availableGroundTruth ? {
816
1048
  id: groundTruth.ground_truth_id,
@@ -847,22 +1079,71 @@ export async function readMetaEvaluation(config, args = {}) {
847
1079
  }
848
1080
  }
849
1081
 
850
- function compareTrialMaps(summary) {
851
- return new Map((summary?.trials ?? []).map(item => [String(item.datasetTrial ?? item.name ?? item.id), normalizeTrial(item, 0)]))
1082
+ function comparisonTrials(summary, lifecycle) {
1083
+ const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
1084
+ if (!Array.isArray(lifecycle?.trials)) return summaryTrials
1085
+ const byExecution = new Map(summaryTrials.map(item => [String(item.id), item]))
1086
+ const byDataset = new Map(summaryTrials.map(item => [String(item.datasetTrial ?? item.name), item]))
1087
+ const matched = new Set()
1088
+ const current = selectedLifecycleTrials(lifecycle).map((item, index) => {
1089
+ const evaluated = byExecution.get(String(item.execution_id)) ?? byDataset.get(String(item.dataset_trial))
1090
+ if (evaluated) matched.add(evaluated)
1091
+ const lifecycleStatus = item.status ?? item.phase
1092
+ return normalizeTrial({
1093
+ ...evaluated,
1094
+ ...item,
1095
+ id: evaluated?.id ?? item.execution_id,
1096
+ name: evaluated?.name ?? item.trial_name ?? item.dataset_trial,
1097
+ datasetTrial: evaluated?.datasetTrial ?? item.dataset_trial,
1098
+ status: lifecycleStatus ?? evaluated?.status,
1099
+ score: item.score ?? evaluated?.score,
1100
+ rewards: evaluated?.rewards ?? {},
1101
+ exception: lifecycleStatus === 'infrastructure-error' ? evaluated?.exception : undefined,
1102
+ terminal: item.terminal,
1103
+ }, index)
1104
+ })
1105
+ return [...current, ...summaryTrials.filter(item => !matched.has(item))]
1106
+ }
1107
+
1108
+ function compareTrialMaps(summary, lifecycle) {
1109
+ return new Map(comparisonTrials(summary, lifecycle).map(item => [String(item.datasetTrial ?? item.name ?? item.id), item]))
1110
+ }
1111
+
1112
+ function isInvalidComparisonTrial(trial) {
1113
+ return trial?.terminal !== false
1114
+ && trial?.score?.valid === false
1115
+ && !['completed-unscored', 'cancelled'].includes(trial?.status)
1116
+ }
1117
+
1118
+ function isInfrastructureComparisonTrial(trial) {
1119
+ return trial?.terminal !== false && (
1120
+ trial?.status === 'infrastructure-error'
1121
+ || trial?.exception?.classification === 'infrastructure'
1122
+ )
852
1123
  }
853
1124
 
854
1125
  export async function readComparison(config, args) {
855
1126
  const baselineJob = safeSegment(args.baseline, 'baseline')
856
1127
  const candidateJob = safeSegment(args.candidate, 'candidate')
857
- const [baseline, candidate, baselineContract, candidateContract] = await Promise.all([
858
- readJson(path.join(jobDirectory(config, baselineJob), SUMMARY_NAME)),
859
- readJson(path.join(jobDirectory(config, candidateJob), SUMMARY_NAME)),
860
- readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-contract.json')),
861
- readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-contract.json')),
1128
+ const projectRoot = path.resolve(config.projectRoot)
1129
+ const [baselineDirectoryCheck, candidateDirectoryCheck] = await Promise.all([
1130
+ directoryCheck(jobDirectory(config, baselineJob), { root: projectRoot }),
1131
+ directoryCheck(jobDirectory(config, candidateJob), { root: projectRoot }),
1132
+ ])
1133
+ if (baselineDirectoryCheck.status !== 'ok' || candidateDirectoryCheck.status !== 'ok') {
1134
+ throw new Error('Both Job directories must be safe')
1135
+ }
1136
+ const [baseline, candidate, baselineContract, candidateContract, baselineLifecycle, candidateLifecycle] = await Promise.all([
1137
+ readJson(path.join(jobDirectory(config, baselineJob), SUMMARY_NAME), { root: projectRoot }),
1138
+ readJson(path.join(jobDirectory(config, candidateJob), SUMMARY_NAME), { root: projectRoot }),
1139
+ readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-contract.json'), { root: projectRoot }),
1140
+ readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-contract.json'), { root: projectRoot }),
1141
+ readJson(path.join(jobDirectory(config, baselineJob), 'trial-lifecycle.json'), { root: projectRoot }),
1142
+ readJson(path.join(jobDirectory(config, candidateJob), 'trial-lifecycle.json'), { root: projectRoot }),
862
1143
  ])
863
1144
  if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
864
- const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
865
- const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
1145
+ const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'), { root: projectRoot })
1146
+ const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'), { root: projectRoot })
866
1147
  const baselineKind = normalizedJobKind(baseline, baselineContext)
867
1148
  const candidateKind = normalizedJobKind(candidate, candidateContext)
868
1149
  if (baselineKind !== CANDIDATE_JOB_KIND || candidateKind !== CANDIDATE_JOB_KIND) {
@@ -870,7 +1151,7 @@ export async function readComparison(config, args) {
870
1151
  code: 'UNSUPPORTED_JOB_KIND_FOR_PROMOTION',
871
1152
  message: 'Historical Generation Evaluation Jobs are diagnostic evidence and cannot be used as a Candidate baseline, comparison, or Promotion Gate input.',
872
1153
  }
873
- return {
1154
+ return rememberAuthoritativeRevision({
874
1155
  schemaVersion: 1,
875
1156
  baselineJob,
876
1157
  candidateJob,
@@ -882,12 +1163,14 @@ export async function readComparison(config, args) {
882
1163
  population: {},
883
1164
  improvedTrials: [],
884
1165
  regressedTrials: [],
1166
+ invalidTrials: [],
1167
+ newInfrastructureExceptions: [],
885
1168
  newExceptions: [],
886
1169
  artifactRegressions: [],
887
1170
  gateEligibility: 'not-applicable',
888
1171
  error,
889
1172
  note: 'Convert reviewed badcases into a fixed regression Dataset before running Candidate comparison or Gate.',
890
- }
1173
+ }, baseline, candidate, baselineContract, candidateContract, baselineLifecycle, candidateLifecycle, baselineContext, candidateContext)
891
1174
  }
892
1175
  const reasons = []
893
1176
  if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
@@ -902,39 +1185,67 @@ export async function readComparison(config, args) {
902
1185
  ? (directions[key] === 'minimize' ? baseline.metrics[key] - candidate.metrics[key] : candidate.metrics[key] - baseline.metrics[key])
903
1186
  : undefined,
904
1187
  }]))
905
- const oldTrials = compareTrialMaps(baseline)
906
- const nextTrials = compareTrialMaps(candidate)
1188
+ const oldTrials = compareTrialMaps(baseline, baselineLifecycle)
1189
+ const nextTrials = compareTrialMaps(candidate, candidateLifecycle)
907
1190
  const improved = []
908
1191
  const regressed = []
909
1192
  const primaryDirection = directions[candidateContract?.primary_metric] ?? 'maximize'
910
1193
  for (const trial of [...oldTrials.keys()].filter(key => nextTrials.has(key)).sort()) {
911
- const oldValue = oldTrials.get(trial).score?.value ?? oldTrials.get(trial).rewards?.reward
912
- const newValue = nextTrials.get(trial).score?.value ?? nextTrials.get(trial).rewards?.reward
913
- if (typeof oldValue !== 'number' || typeof newValue !== 'number' || oldValue === newValue) continue
1194
+ const oldTrial = oldTrials.get(trial)
1195
+ const newTrial = nextTrials.get(trial)
1196
+ if (oldTrial.score?.valid !== true || newTrial.score?.valid !== true) continue
1197
+ const oldValue = oldTrial.score.value ?? oldTrial.rewards?.reward
1198
+ const newValue = newTrial.score.value ?? newTrial.rewards?.reward
1199
+ if (!Number.isFinite(oldValue) || !Number.isFinite(newValue) || oldValue === newValue) continue
914
1200
  const item = { trial, baseline: oldValue, candidate: newValue, delta: newValue - oldValue }
915
1201
  const isImproved = primaryDirection === 'minimize' ? newValue < oldValue : newValue > oldValue
916
1202
  ;(isImproved ? improved : regressed).push(item)
917
1203
  }
1204
+ const invalidTrials = [...nextTrials.entries()]
1205
+ .filter(([, trial]) => isInvalidComparisonTrial(trial))
1206
+ .map(([trial, candidateTrial]) => ({
1207
+ trial,
1208
+ status: candidateTrial.status,
1209
+ invalidReasons: Array.isArray(candidateTrial.score?.invalid_reasons)
1210
+ ? candidateTrial.score.invalid_reasons.slice(0, 20).map(String)
1211
+ : [],
1212
+ baselineValid: oldTrials.get(trial)?.score?.valid,
1213
+ candidateValid: false,
1214
+ }))
1215
+ const newInfrastructureExceptions = [...nextTrials.entries()]
1216
+ .filter(([trial, candidateTrial]) => (
1217
+ isInfrastructureComparisonTrial(candidateTrial)
1218
+ && !isInfrastructureComparisonTrial(oldTrials.get(trial))
1219
+ ))
1220
+ .map(([trial, candidateTrial]) => ({
1221
+ trial,
1222
+ baselineStatus: oldTrials.get(trial)?.status,
1223
+ candidateStatus: candidateTrial.status,
1224
+ ...(candidateTrial.exception ? { exception: candidateTrial.exception } : {}),
1225
+ }))
918
1226
  const baselineExceptions = new Set((baseline.exceptions ?? []).map(item => String(item.trial)))
919
1227
  const newExceptions = (candidate.exceptions ?? []).filter(item => !baselineExceptions.has(String(item.trial)))
920
1228
  const artifactRegressions = (baseline.artifact_validation?.valid && !candidate.artifact_validation?.valid) ? ['artifact-validation'] : []
921
- return {
1229
+ return rememberAuthoritativeRevision({
922
1230
  schemaVersion: 1, baselineJob, candidateJob, comparable: reasons.length === 0, comparabilityReasons: reasons,
923
1231
  metrics, population: { baseline: baseline.n_trials, candidate: candidate.n_trials, baselineValid: baseline.n_valid_scores, candidateValid: candidate.n_valid_scores },
924
- improvedTrials: improved, regressedTrials: regressed, newExceptions, artifactRegressions,
1232
+ improvedTrials: improved, regressedTrials: regressed, invalidTrials, newInfrastructureExceptions, newExceptions, artifactRegressions,
925
1233
  gateEligibility: reasons.length ? 'not-comparable' : 'requires-explicit-gate',
926
1234
  note: 'This read-only comparison never runs Gate, promotes a Candidate, deploys, or publishes.',
927
- }
1235
+ }, baseline, candidate, baselineContract, candidateContract, baselineLifecycle, candidateLifecycle, baselineContext, candidateContext)
928
1236
  }
929
1237
 
930
1238
  export async function readEvaluatorGovernance(config, args) {
931
1239
  const job = safeSegment(args.job, 'job')
932
1240
  const directory = jobDirectory(config, job)
1241
+ const projectRoot = path.resolve(config.projectRoot)
1242
+ const check = await directoryCheck(directory, { root: projectRoot })
1243
+ if (check.status !== 'ok') throw new Error('Job not found')
933
1244
  const [stack, sources, contract, context] = await Promise.all([
934
- readJson(path.join(directory, 'evaluation-stack-manifest.json')),
935
- readJson(path.join(directory, 'evaluation-stack-sources.json'), { maxText: MAX_SOURCE_BYTES }),
936
- readJson(path.join(directory, 'evaluation-contract.json')),
937
- readJson(path.join(directory, 'evaluation-context.json')),
1245
+ readJson(path.join(directory, 'evaluation-stack-manifest.json'), { root: projectRoot }),
1246
+ readJson(path.join(directory, 'evaluation-stack-sources.json'), { maxText: MAX_SOURCE_BYTES, root: projectRoot }),
1247
+ readJson(path.join(directory, 'evaluation-contract.json'), { root: projectRoot }),
1248
+ readJson(path.join(directory, 'evaluation-context.json'), { root: projectRoot }),
938
1249
  ])
939
1250
  if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
940
1251
  const historicalSources = sources?.schema_version === 1 && sources.stack_digest === stack.digest
@@ -944,18 +1255,26 @@ export async function readEvaluatorGovernance(config, args) {
944
1255
  for (const [role, component] of Object.entries(stack.components ?? {})) {
945
1256
  const entry = component?.entry
946
1257
  const snapshot = historicalSources?.components?.[role]
947
- const snapshotFile = snapshot?.files?.find(item => item.path === entry && item.text)
1258
+ // The descriptor is identity/configuration, not the editable prompt. Prefer
1259
+ // an authorized historical prompt/implementation without reading live text.
1260
+ const editable = role === 'evaluator' ? component?.interface?.editable_files ?? [] : []
1261
+ const preferred = editable.find(item => item.role === 'prompt') ?? editable.find(item => item.role === 'implementation')
1262
+ const snapshotFile = snapshot?.files?.find(item => item.path === preferred?.path && item.text)
1263
+ ?? snapshot?.files?.find(item => item.path === entry && item.text)
948
1264
  ?? snapshot?.files?.find(item => item.text)
949
1265
  const source = snapshotFile
950
1266
  ? { ...snapshotFile, source: 'job-snapshot', readOnly: true }
951
1267
  : entry
952
- ? { ...(await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot)), source: 'historical-live-fallback', readOnly: true }
1268
+ ? { ...(await readSafeText(resolveWithin(projectRoot, entry, `${role}.entry`), projectRoot)), source: 'historical-live-fallback', readOnly: true }
953
1269
  : { error: 'entry unavailable', source: 'unavailable', readOnly: true }
954
1270
  components[role] = { ...component, source }
955
1271
  }
956
1272
  let comparison
957
1273
  if (args.compareJob) {
958
- const other = await readJson(path.join(jobDirectory(config, safeSegment(args.compareJob, 'compareJob')), 'evaluation-stack-manifest.json'))
1274
+ const compareDirectory = jobDirectory(config, safeSegment(args.compareJob, 'compareJob'))
1275
+ const compareCheck = await directoryCheck(compareDirectory, { root: projectRoot })
1276
+ if (compareCheck.status !== 'ok') throw new Error('Comparison Job not found')
1277
+ const other = await readJson(path.join(compareDirectory, 'evaluation-stack-manifest.json'), { root: projectRoot })
959
1278
  const changes = []
960
1279
  for (const role of new Set([...Object.keys(stack.components ?? {}), ...Object.keys(other?.components ?? {})])) {
961
1280
  const before = other?.components?.[role]