yam-flow 2.2.0 → 2.3.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.
@@ -21,6 +21,10 @@ export type LoopStageStatus = 'passed' | 'failed' | 'blocked' | 'skipped' | 'par
21
21
  export type LoopEvidenceLevel = 'none' | 'fixture' | 'smoke' | 'local' | 'real';
22
22
  export type ToolIntent = 'read_only' | 'write' | 'destructive' | 'runtime' | 'visual' | 'publish';
23
23
  export type ReadinessState = 'usable' | 'degraded' | 'blocked' | 'unknown';
24
+ export type MissionLaneRole = 'implementer' | 'reviewer' | 'ux_verifier' | 'doctor' | 'other';
25
+ export type MissionAccessMode = 'read_only' | 'write';
26
+ export type MissionLifecycleStatus = 'pending' | 'running' | 'stopped' | 'failed' | 'cancelled';
27
+ export type MissionOutcome = 'passed' | 'failed' | 'blocked' | 'ambiguous';
24
28
 
25
29
  export interface SafetyHit {
26
30
  level: SafetyLevel;
@@ -134,6 +138,53 @@ export interface MissionPatchEnvelope {
134
138
  truth_status: TruthStatus;
135
139
  }
136
140
 
141
+ export interface MissionContractCheck {
142
+ id: string;
143
+ label: string;
144
+ status: 'pass' | 'fail' | 'warning';
145
+ next_action: string;
146
+ }
147
+
148
+ export interface MissionSubagentReceipt {
149
+ schema: 'yam.mission-subagent-receipt.v1';
150
+ receipt_id: string;
151
+ generated_at: string;
152
+ thread_id: string;
153
+ lane_id: string;
154
+ agent_id: string;
155
+ role: MissionLaneRole;
156
+ access_mode: MissionAccessMode;
157
+ lifecycle_status: MissionLifecycleStatus;
158
+ outcome: MissionOutcome;
159
+ assigned_scope: string;
160
+ changed_files: string[];
161
+ verification_evidence: string[];
162
+ remaining_risks: string[];
163
+ checks: MissionContractCheck[];
164
+ blockers: string[];
165
+ warnings: string[];
166
+ completion_eligible: boolean;
167
+ truth_status: TruthStatus;
168
+ }
169
+
170
+ export interface MissionCompletionGate {
171
+ schema: 'yam.mission-completion-gate.v1';
172
+ generated_at: string;
173
+ expected_thread_ids: string[];
174
+ receipt_count: number;
175
+ receipts: MissionSubagentReceipt[];
176
+ missing_thread_ids: string[];
177
+ duplicate_thread_ids: string[];
178
+ unexpected_thread_ids: string[];
179
+ invalid_thread_ids: string[];
180
+ read_only_violations: string[];
181
+ blockers: string[];
182
+ warnings: string[];
183
+ ready_to_claim_complete: boolean;
184
+ next_action: string;
185
+ truth_status: TruthStatus;
186
+ }
187
+
137
188
  export interface RuntimeBackendEvidence {
138
189
  schema: 'yam.runtime-backend-evidence.v1';
139
190
  backend: RuntimeBackend;
@@ -300,6 +351,8 @@ export interface ProofSummary {
300
351
  unverified?: unknown;
301
352
  visualProvenance?: unknown;
302
353
  missionEnvelope?: unknown;
354
+ missionReceipt?: unknown;
355
+ missionCompletion?: unknown;
303
356
  rollbackHint?: unknown;
304
357
  runtimeBackendEvidence?: unknown;
305
358
  mediaProof?: unknown;
@@ -336,6 +389,8 @@ export interface YamCompletionProof {
336
389
  runtime: string[];
337
390
  visualProvenance: string[];
338
391
  missionEnvelope: string[];
392
+ missionReceipt: string[];
393
+ missionCompletion: string[];
339
394
  rollbackHint: string[];
340
395
  runtimeBackendEvidence: string[];
341
396
  mediaProof: string[];
@@ -509,6 +564,137 @@ export function buildMissionPatchEnvelope(input: Partial<MissionPatchEnvelope> =
509
564
  };
510
565
  }
511
566
 
567
+ export function buildMissionSubagentReceipt(input: Partial<MissionSubagentReceipt> & Record<string, unknown> = {}): MissionSubagentReceipt {
568
+ const role = normalizeMissionLaneRole(input.role);
569
+ const accessMode = normalizeMissionAccessMode(input.access_mode, role);
570
+ const lifecycleStatus = normalizeMissionLifecycleStatus(input.lifecycle_status);
571
+ const outcome = normalizeMissionOutcome(input.outcome);
572
+ const threadId = String(input.thread_id || '');
573
+ const assignedScope = String(input.assigned_scope || '');
574
+ const changedFiles = asList(input.changed_files);
575
+ const verificationEvidence = asList(input.verification_evidence);
576
+ const remainingRisks = asList(input.remaining_risks);
577
+ const readOnlyRole = role === 'reviewer' || role === 'doctor';
578
+ const checks = [
579
+ missionContractCheck('thread_id', 'Thread id is recorded', Boolean(threadId), 'record --thread-id for every spawned thread'),
580
+ missionContractCheck('assigned_scope', 'Assigned scope is recorded', Boolean(assignedScope), 'record the bounded lane scope'),
581
+ missionContractCheck('read_only_role', 'Reviewer and doctor lanes stay read-only', !readOnlyRole || (accessMode === 'read_only' && changedFiles.length === 0), 'rerun reviewer/doctor without write access or changed files'),
582
+ missionContractCheck('terminal_lifecycle', 'Lifecycle reached a terminal state', ['stopped', 'failed', 'cancelled'].includes(lifecycleStatus), 'wait for a terminal lifecycle event before finalization'),
583
+ missionContractCheck('explicit_outcome', 'Outcome is explicit and unambiguous', outcome !== 'ambiguous', 'record passed, failed, or blocked; a stop event alone is not success evidence'),
584
+ missionContractCheck('passed_evidence', 'Passed outcome has verification evidence', outcome !== 'passed' || verificationEvidence.length > 0, 'attach at least one concrete verification item for a passed outcome')
585
+ ];
586
+ const blockers = checks.filter((check) => check.status === 'fail').map((check) => `${check.id}: ${check.next_action}`);
587
+ if (outcome === 'failed' || outcome === 'blocked') blockers.push(`outcome_${outcome}: resolve or hand off this thread before mission completion`);
588
+ if ((lifecycleStatus === 'failed' || lifecycleStatus === 'cancelled') && outcome === 'passed') {
589
+ blockers.push('lifecycle_outcome_conflict: a failed or cancelled lifecycle cannot claim a passed outcome');
590
+ }
591
+ const warnings = remainingRisks.length ? ['remaining risks are recorded and must be included in the mission handoff'] : [];
592
+ const completionEligible = blockers.length === 0 && lifecycleStatus === 'stopped' && outcome === 'passed';
593
+ const truth: TruthStatus = completionEligible ? 'verified' : blockers.length ? 'blocked' : 'partial';
594
+ return {
595
+ schema: 'yam.mission-subagent-receipt.v1',
596
+ receipt_id: String(input.receipt_id || `receipt-${threadId || 'unassigned'}`),
597
+ generated_at: String(input.generated_at || new Date().toISOString()),
598
+ thread_id: threadId,
599
+ lane_id: String(input.lane_id || threadId),
600
+ agent_id: String(input.agent_id || ''),
601
+ role,
602
+ access_mode: accessMode,
603
+ lifecycle_status: lifecycleStatus,
604
+ outcome,
605
+ assigned_scope: assignedScope,
606
+ changed_files: changedFiles,
607
+ verification_evidence: verificationEvidence,
608
+ remaining_risks: remainingRisks,
609
+ checks,
610
+ blockers: uniqueStrings(blockers),
611
+ warnings,
612
+ completion_eligible: completionEligible,
613
+ truth_status: truth
614
+ };
615
+ }
616
+
617
+ export function buildMissionCompletionGate(input: Partial<MissionCompletionGate> & Record<string, unknown> = {}): MissionCompletionGate {
618
+ const expectedThreadIds = uniqueStrings(asList(input.expected_thread_ids));
619
+ const receipts = asObjectList(input.receipts).map((receipt) => buildMissionSubagentReceipt(receipt));
620
+ const receiptCounts = new Map<string, number>();
621
+ for (const receipt of receipts) {
622
+ receiptCounts.set(receipt.thread_id, (receiptCounts.get(receipt.thread_id) || 0) + 1);
623
+ }
624
+ const missingThreadIds = expectedThreadIds.filter((threadId) => !receiptCounts.has(threadId));
625
+ const duplicateThreadIds = [...receiptCounts.entries()].filter(([, count]) => count > 1).map(([threadId]) => threadId).filter(Boolean);
626
+ const unexpectedThreadIds = uniqueStrings(receipts.map((receipt) => receipt.thread_id).filter((threadId) => threadId && !expectedThreadIds.includes(threadId)));
627
+ const expectedReceipts = receipts.filter((receipt) => expectedThreadIds.includes(receipt.thread_id));
628
+ const invalidThreadIds = uniqueStrings([
629
+ ...expectedReceipts.filter((receipt) => !receipt.completion_eligible).map((receipt) => receipt.thread_id),
630
+ ...receipts.filter((receipt) => !receipt.thread_id).map((receipt) => `missing:${receipt.receipt_id}`)
631
+ ]);
632
+ const readOnlyViolations = uniqueStrings(receipts
633
+ .filter((receipt) => receipt.checks.some((check) => check.id === 'read_only_role' && check.status === 'fail'))
634
+ .map((receipt) => receipt.thread_id || receipt.lane_id));
635
+ const blockers: string[] = [];
636
+ if (!expectedThreadIds.length) blockers.push('expected_thread_inventory_missing: record every spawned thread before finalization');
637
+ if (missingThreadIds.length) blockers.push(`missing_receipts: ${missingThreadIds.join(', ')}`);
638
+ if (duplicateThreadIds.length) blockers.push(`duplicate_receipts: ${duplicateThreadIds.join(', ')}`);
639
+ if (unexpectedThreadIds.length) blockers.push(`unexpected_receipts: ${unexpectedThreadIds.join(', ')}`);
640
+ if (invalidThreadIds.length) blockers.push(`invalid_or_incomplete_receipts: ${invalidThreadIds.join(', ')}`);
641
+ if (readOnlyViolations.length) blockers.push(`read_only_contract_violations: ${readOnlyViolations.join(', ')}`);
642
+ blockers.push(...asList(input.blockers));
643
+ const warnings = uniqueStrings(asList(input.warnings));
644
+ const ready = blockers.length === 0 && expectedReceipts.length === expectedThreadIds.length;
645
+ return {
646
+ schema: 'yam.mission-completion-gate.v1',
647
+ generated_at: String(input.generated_at || new Date().toISOString()),
648
+ expected_thread_ids: expectedThreadIds,
649
+ receipt_count: receipts.length,
650
+ receipts,
651
+ missing_thread_ids: missingThreadIds,
652
+ duplicate_thread_ids: duplicateThreadIds,
653
+ unexpected_thread_ids: unexpectedThreadIds,
654
+ invalid_thread_ids: invalidThreadIds,
655
+ read_only_violations: readOnlyViolations,
656
+ blockers: uniqueStrings(blockers),
657
+ warnings,
658
+ ready_to_claim_complete: ready,
659
+ next_action: ready ? 'attach this gate to the mission proof summary' : blockers[0] || 'resolve mission receipt warnings',
660
+ truth_status: ready ? 'verified' : 'blocked'
661
+ };
662
+ }
663
+
664
+ function missionContractCheck(id: string, label: string, passed: boolean, nextAction: string): MissionContractCheck {
665
+ return {
666
+ id,
667
+ label,
668
+ status: passed ? 'pass' : 'fail',
669
+ next_action: nextAction
670
+ };
671
+ }
672
+
673
+ function normalizeMissionLaneRole(value: unknown = ''): MissionLaneRole {
674
+ const text = String(value || '').toLowerCase().replace(/[- ]/g, '_');
675
+ if (['implementer', 'reviewer', 'ux_verifier', 'doctor'].includes(text)) return text as MissionLaneRole;
676
+ return 'other';
677
+ }
678
+
679
+ function normalizeMissionAccessMode(value: unknown = '', role: MissionLaneRole = 'other'): MissionAccessMode {
680
+ const text = String(value || '').toLowerCase().replace(/[- ]/g, '_');
681
+ if (text === 'read_only' || text === 'readonly') return 'read_only';
682
+ if (text === 'write') return 'write';
683
+ return role === 'reviewer' || role === 'doctor' ? 'read_only' : 'write';
684
+ }
685
+
686
+ function normalizeMissionLifecycleStatus(value: unknown = ''): MissionLifecycleStatus {
687
+ const text = String(value || '').toLowerCase();
688
+ if (['pending', 'running', 'stopped', 'failed', 'cancelled'].includes(text)) return text as MissionLifecycleStatus;
689
+ return 'pending';
690
+ }
691
+
692
+ function normalizeMissionOutcome(value: unknown = ''): MissionOutcome {
693
+ const text = String(value || '').toLowerCase();
694
+ if (['passed', 'failed', 'blocked'].includes(text)) return text as MissionOutcome;
695
+ return 'ambiguous';
696
+ }
697
+
512
698
  export function buildRuntimeBackendEvidence(input: Partial<RuntimeBackendEvidence> = {}): RuntimeBackendEvidence {
513
699
  const backend = normalizeRuntimeBackend(input.backend);
514
700
  const claim = normalizeRuntimeClaim(input.claim);
@@ -997,6 +1183,12 @@ export function classifyEvidenceTruth(summary: ProofSummary = {}, options: Proof
997
1183
  for (const envelope of asList(summary.missionEnvelope)) {
998
1184
  rows.push(evidenceRow('evidence', envelope, metadataTruth(envelope, 'partial')));
999
1185
  }
1186
+ for (const receipt of asList(summary.missionReceipt)) {
1187
+ rows.push(evidenceRow('evidence', receipt, metadataTruth(receipt, 'partial')));
1188
+ }
1189
+ for (const completion of asList(summary.missionCompletion)) {
1190
+ rows.push(evidenceRow('evidence', completion, metadataTruth(completion, 'partial')));
1191
+ }
1000
1192
  for (const rollback of asList(summary.rollbackHint)) {
1001
1193
  rows.push(evidenceRow('evidence', rollback, metadataTruth(rollback, 'partial')));
1002
1194
  }
@@ -1046,6 +1238,19 @@ export function applyProofTruthCaps(summary: ProofSummary = {}, options: ProofOp
1046
1238
  unverified.push('ueye_design_completion_not_verified');
1047
1239
  }
1048
1240
  }
1241
+ const missionRoute = String(summary.route || '').replace(/^\$/, '').toLowerCase() === 'mission';
1242
+ const missionCompletionValues = asValueList(summary.missionCompletion);
1243
+ if (missionRoute && ['verified', 'proven'].includes(requested)) {
1244
+ if (!missionCompletionValues.length) {
1245
+ cappedTruth = weakestTruth(cappedTruth, 'partial');
1246
+ unverified.push('mission_completion_gate_missing');
1247
+ } else {
1248
+ const missionTruth = missionCompletionCap(missionCompletionValues);
1249
+ cappedTruth = weakestTruth(cappedTruth, missionTruth);
1250
+ if (missionTruth === 'blocked') blockers.push('mission_completion_gate_blocked');
1251
+ if (!['verified', 'proven'].includes(missionTruth)) unverified.push('mission_completion_not_verified');
1252
+ }
1253
+ }
1049
1254
  if (requested !== cappedTruth) {
1050
1255
  caps.push(`requested ${requested} capped to ${cappedTruth} by evidence`);
1051
1256
  }
@@ -1102,6 +1307,8 @@ export function buildYamCompletionProof(summary: ProofSummary = {}, options: Pro
1102
1307
  runtime: asList(summary.runtime),
1103
1308
  visualProvenance: asList(summary.visualProvenance),
1104
1309
  missionEnvelope: asList(summary.missionEnvelope),
1310
+ missionReceipt: asList(summary.missionReceipt),
1311
+ missionCompletion: asList(summary.missionCompletion),
1105
1312
  rollbackHint: asList(summary.rollbackHint),
1106
1313
  runtimeBackendEvidence: asList(summary.runtimeBackendEvidence),
1107
1314
  mediaProof: asList(summary.mediaProof),
@@ -1181,6 +1388,17 @@ function designCompletionCap(value: unknown = ''): TruthStatus | null {
1181
1388
  return items.reduce<TruthStatus>((weakest, item) => weakestTruth(weakest, readStructuredTruth(item, 'partial')), 'proven');
1182
1389
  }
1183
1390
 
1391
+ function missionCompletionCap(value: unknown = ''): TruthStatus {
1392
+ const items = asValueList(value);
1393
+ if (!items.length) return 'partial';
1394
+ return items.reduce<TruthStatus>((weakest, item) => {
1395
+ const parsed = parseStructuredValue(item);
1396
+ if (!parsed || typeof parsed !== 'object') return weakestTruth(weakest, 'blocked');
1397
+ const gate = buildMissionCompletionGate(parsed as Record<string, unknown>);
1398
+ return weakestTruth(weakest, gate.truth_status);
1399
+ }, 'proven');
1400
+ }
1401
+
1184
1402
  function strongestSupportedTruth(rows: EvidenceRow[] = []): TruthStatus {
1185
1403
  if (rows.some((row) => row.truth === 'blocked')) return 'blocked';
1186
1404
  if (rows.some((row) => row.truth === 'real_required_missing')) return 'real_required_missing';
@@ -1301,6 +1519,27 @@ function asList(value?: unknown): string[] {
1301
1519
  return Array.isArray(value) ? value.filter(Boolean).map(String) : [String(value)].filter(Boolean);
1302
1520
  }
1303
1521
 
1522
+ function asValueList(value?: unknown): unknown[] {
1523
+ if (value === undefined || value === null || value === '') return [];
1524
+ return Array.isArray(value) ? value.filter(Boolean) : [value];
1525
+ }
1526
+
1527
+ function parseStructuredValue(value: unknown): Record<string, unknown> | null {
1528
+ if (value && typeof value === 'object') return value as Record<string, unknown>;
1529
+ try {
1530
+ const parsed = JSON.parse(String(value || ''));
1531
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
1532
+ } catch {
1533
+ return null;
1534
+ }
1535
+ }
1536
+
1537
+ function asObjectList(value?: unknown): Array<Record<string, unknown>> {
1538
+ if (!value) return [];
1539
+ const items = Array.isArray(value) ? value : [value];
1540
+ return items.filter((item) => Boolean(item) && typeof item === 'object') as Array<Record<string, unknown>>;
1541
+ }
1542
+
1304
1543
  function uniqueStrings(values: unknown[] = []): string[] {
1305
1544
  return [...new Set(values.filter(Boolean).map(String))];
1306
1545
  }
@@ -0,0 +1,354 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ export interface InspectedImageFile {
7
+ path: string;
8
+ sha256: string;
9
+ bytes: number;
10
+ dimensions: string;
11
+ }
12
+
13
+ export interface UeyeAssetEntry {
14
+ id: string;
15
+ file_path: string;
16
+ source_url: string;
17
+ source_page_url: string;
18
+ retrieved_at: string;
19
+ license_note: string;
20
+ operator_provided: boolean;
21
+ do_not_replace: boolean;
22
+ allowed_for_edit: boolean;
23
+ sha256: string;
24
+ bytes: number;
25
+ dimensions: string;
26
+ }
27
+
28
+ export interface UeyeAssetManifest {
29
+ schema: 'yam.ueye-asset-manifest.v1';
30
+ updated_at: string;
31
+ assets: UeyeAssetEntry[];
32
+ }
33
+
34
+ export interface UeyeRevisionEntry {
35
+ artifact_id: string;
36
+ round: number;
37
+ source_path: string;
38
+ archived_path: string;
39
+ archived_at: string;
40
+ sha256: string;
41
+ bytes: number;
42
+ dimensions: string;
43
+ }
44
+
45
+ export interface UeyeRevisionHistory {
46
+ schema: 'yam.ueye-revision-history.v1';
47
+ updated_at: string;
48
+ revisions: UeyeRevisionEntry[];
49
+ }
50
+
51
+ export async function inspectImageFile(file: string): Promise<InspectedImageFile> {
52
+ const absolute = path.resolve(file);
53
+ const buffer = await fsp.readFile(absolute);
54
+ const stat = await fsp.stat(absolute);
55
+ return {
56
+ path: absolute,
57
+ sha256: createHash('sha256').update(buffer).digest('hex'),
58
+ bytes: stat.size,
59
+ dimensions: imageDimensions(buffer)
60
+ };
61
+ }
62
+
63
+ export async function upsertUeyeAsset(input: {
64
+ manifest_path: string;
65
+ id: string;
66
+ file: string;
67
+ source_url?: string;
68
+ source_page_url?: string;
69
+ retrieved_at?: string;
70
+ license_note?: string;
71
+ operator_provided?: boolean;
72
+ do_not_replace?: boolean;
73
+ allowed_for_edit?: boolean;
74
+ replace?: boolean;
75
+ }) {
76
+ const manifestPath = path.resolve(input.manifest_path);
77
+ const id = safeId(input.id, 'asset id');
78
+ const info = await inspectImageFile(input.file);
79
+ validateOptionalHttpUrl(input.source_url, 'source_url');
80
+ validateOptionalHttpUrl(input.source_page_url, 'source_page_url');
81
+ const manifest = await readAssetManifest(manifestPath);
82
+ const existingIndex = manifest.assets.findIndex((asset) => asset.id === id);
83
+ const existing = existingIndex >= 0 ? manifest.assets[existingIndex] : null;
84
+ if (existing?.do_not_replace && existing.sha256 !== info.sha256 && !input.replace) {
85
+ throw new Error(`asset ${id} is protected by do_not_replace; pass --replace only after explicit review`);
86
+ }
87
+ const duplicate = manifest.assets.find((asset) => asset.id !== id && asset.sha256 === info.sha256);
88
+ if (duplicate) throw new Error(`asset content already exists as ${duplicate.id}; reuse that asset id`);
89
+ const entry: UeyeAssetEntry = {
90
+ id,
91
+ file_path: relativePortablePath(path.dirname(manifestPath), info.path),
92
+ source_url: String(input.source_url ?? existing?.source_url ?? ''),
93
+ source_page_url: String(input.source_page_url ?? existing?.source_page_url ?? ''),
94
+ retrieved_at: String(input.retrieved_at ?? existing?.retrieved_at ?? new Date().toISOString()),
95
+ license_note: String(input.license_note ?? existing?.license_note ?? ''),
96
+ operator_provided: input.operator_provided ?? existing?.operator_provided ?? (!input.source_url && !input.source_page_url),
97
+ do_not_replace: input.do_not_replace ?? existing?.do_not_replace ?? false,
98
+ allowed_for_edit: input.allowed_for_edit ?? existing?.allowed_for_edit ?? false,
99
+ sha256: info.sha256,
100
+ bytes: info.bytes,
101
+ dimensions: info.dimensions
102
+ };
103
+ if (existingIndex >= 0) manifest.assets[existingIndex] = entry;
104
+ else manifest.assets.push(entry);
105
+ manifest.assets.sort((left, right) => left.id.localeCompare(right.id));
106
+ manifest.updated_at = new Date().toISOString();
107
+ await writeJsonAtomic(manifestPath, manifest);
108
+ const warnings = assetWarnings(entry);
109
+ return {
110
+ schema: 'yam.ueye-asset-update.v1',
111
+ action: existing ? 'updated' : 'added',
112
+ manifest_path: manifestPath,
113
+ asset: entry,
114
+ warnings,
115
+ next_action: warnings[0] || 'run `yam ueye asset verify` before relying on this manifest for a done claim',
116
+ truth_status: warnings.length ? 'partial' : 'verified'
117
+ };
118
+ }
119
+
120
+ export async function verifyUeyeAssetManifest(manifestFile: string) {
121
+ const manifestPath = path.resolve(manifestFile);
122
+ const manifest = await readAssetManifest(manifestPath, false);
123
+ const errors: string[] = [];
124
+ const warnings: string[] = [];
125
+ const seenHashes = new Map<string, string>();
126
+ for (const asset of manifest.assets) {
127
+ try {
128
+ validateOptionalHttpUrl(asset.source_url, `${asset.id}.source_url`);
129
+ validateOptionalHttpUrl(asset.source_page_url, `${asset.id}.source_page_url`);
130
+ const file = resolveManifestFile(manifestPath, asset.file_path);
131
+ const info = await inspectImageFile(file);
132
+ if (info.sha256 !== asset.sha256) errors.push(`${asset.id}: sha256 mismatch; the asset changed after it was recorded`);
133
+ if (asset.bytes && info.bytes !== asset.bytes) errors.push(`${asset.id}: byte size mismatch`);
134
+ if (asset.dimensions && info.dimensions !== asset.dimensions) errors.push(`${asset.id}: dimensions mismatch`);
135
+ const duplicate = seenHashes.get(info.sha256);
136
+ if (duplicate && duplicate !== asset.id) errors.push(`${asset.id}: duplicates ${duplicate} by sha256`);
137
+ else seenHashes.set(info.sha256, asset.id);
138
+ warnings.push(...assetWarnings(asset));
139
+ } catch (error) {
140
+ errors.push(`${asset.id}: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ }
143
+ if (!manifest.assets.length) errors.push('asset manifest is empty');
144
+ const uniqueWarnings = uniqueStrings(warnings);
145
+ return {
146
+ schema: 'yam.ueye-asset-verification.v1',
147
+ manifest_path: manifestPath,
148
+ asset_count: manifest.assets.length,
149
+ protected_count: manifest.assets.filter((asset) => asset.do_not_replace).length,
150
+ editable_count: manifest.assets.filter((asset) => asset.allowed_for_edit).length,
151
+ errors: uniqueStrings(errors),
152
+ warnings: uniqueWarnings,
153
+ ready: errors.length === 0,
154
+ next_action: errors[0] || uniqueWarnings[0] || 'attach this manifest verification to the Ueye report when asset provenance matters',
155
+ truth_status: errors.length ? 'blocked' : uniqueWarnings.length ? 'partial' : 'verified'
156
+ };
157
+ }
158
+
159
+ export async function archiveUeyeRevision(input: {
160
+ file: string;
161
+ root: string;
162
+ manifest_path?: string;
163
+ round: number;
164
+ artifact_id?: string;
165
+ }) {
166
+ const round = Number(input.round);
167
+ if (!Number.isInteger(round) || round < 1) throw new Error('revision round must be a positive integer');
168
+ const source = await inspectImageFile(input.file);
169
+ const root = path.resolve(input.root);
170
+ const manifestPath = path.resolve(input.manifest_path || path.join(root, 'manifest.json'));
171
+ const extension = path.extname(source.path);
172
+ const artifactId = safeId(input.artifact_id || path.basename(source.path, extension), 'artifact id');
173
+ const roundDir = path.join(root, `r${round}`);
174
+ const archivedPath = path.join(roundDir, `${artifactId}${extension}`);
175
+ await fsp.mkdir(roundDir, { recursive: true });
176
+ let action = 'archived';
177
+ try {
178
+ await fsp.copyFile(source.path, archivedPath, fsConstants.COPYFILE_EXCL);
179
+ } catch (error: any) {
180
+ if (error?.code !== 'EEXIST') throw error;
181
+ const existing = await inspectImageFile(archivedPath);
182
+ if (existing.sha256 !== source.sha256) {
183
+ throw new Error(`revision r${round}/${artifactId} already exists with different content; choose a new round`);
184
+ }
185
+ action = 'already_archived';
186
+ }
187
+ const history = await readRevisionHistory(manifestPath);
188
+ const existingIndex = history.revisions.findIndex((entry) => entry.round === round && entry.artifact_id === artifactId);
189
+ const entry: UeyeRevisionEntry = {
190
+ artifact_id: artifactId,
191
+ round,
192
+ source_path: source.path,
193
+ archived_path: relativePortablePath(path.dirname(manifestPath), archivedPath),
194
+ archived_at: new Date().toISOString(),
195
+ sha256: source.sha256,
196
+ bytes: source.bytes,
197
+ dimensions: source.dimensions
198
+ };
199
+ if (existingIndex >= 0) {
200
+ if (history.revisions[existingIndex].sha256 !== entry.sha256) {
201
+ throw new Error(`revision history already records different content for r${round}/${artifactId}`);
202
+ }
203
+ entry.archived_at = history.revisions[existingIndex].archived_at;
204
+ history.revisions[existingIndex] = entry;
205
+ } else {
206
+ history.revisions.push(entry);
207
+ }
208
+ history.revisions.sort((left, right) => left.round - right.round || left.artifact_id.localeCompare(right.artifact_id));
209
+ history.updated_at = new Date().toISOString();
210
+ await writeJsonAtomic(manifestPath, history);
211
+ return {
212
+ schema: 'yam.ueye-revision-archive.v1',
213
+ action,
214
+ manifest_path: manifestPath,
215
+ revision: entry,
216
+ next_action: 'edit the live artifact, then use a later round for the next preserved version',
217
+ truth_status: 'verified'
218
+ };
219
+ }
220
+
221
+ export async function verifyUeyeRevisionHistory(manifestFile: string) {
222
+ const manifestPath = path.resolve(manifestFile);
223
+ const history = await readRevisionHistory(manifestPath, false);
224
+ const errors: string[] = [];
225
+ const seen = new Set<string>();
226
+ for (const revision of history.revisions) {
227
+ const key = `${revision.round}:${revision.artifact_id}`;
228
+ if (seen.has(key)) errors.push(`${key}: duplicate revision entry`);
229
+ seen.add(key);
230
+ try {
231
+ const file = resolveManifestFile(manifestPath, revision.archived_path);
232
+ const info = await inspectImageFile(file);
233
+ if (info.sha256 !== revision.sha256) errors.push(`${key}: sha256 mismatch; archived content changed`);
234
+ if (revision.bytes && info.bytes !== revision.bytes) errors.push(`${key}: byte size mismatch`);
235
+ if (revision.dimensions && info.dimensions !== revision.dimensions) errors.push(`${key}: dimensions mismatch`);
236
+ } catch (error) {
237
+ errors.push(`${key}: ${error instanceof Error ? error.message : String(error)}`);
238
+ }
239
+ }
240
+ if (!history.revisions.length) errors.push('revision history is empty');
241
+ return {
242
+ schema: 'yam.ueye-revision-verification.v1',
243
+ manifest_path: manifestPath,
244
+ revision_count: history.revisions.length,
245
+ rounds: [...new Set(history.revisions.map((entry) => entry.round))].sort((left, right) => left - right),
246
+ errors: uniqueStrings(errors),
247
+ ready: errors.length === 0,
248
+ next_action: errors[0] || 'attach this revision history to the Ueye report when iterative visual work matters',
249
+ truth_status: errors.length ? 'blocked' : 'verified'
250
+ };
251
+ }
252
+
253
+ async function readAssetManifest(file: string, allowMissing = true): Promise<UeyeAssetManifest> {
254
+ try {
255
+ const data = JSON.parse(await fsp.readFile(file, 'utf8'));
256
+ if (data.schema !== 'yam.ueye-asset-manifest.v1' || !Array.isArray(data.assets)) throw new Error('invalid Ueye asset manifest schema');
257
+ return data;
258
+ } catch (error: any) {
259
+ if (allowMissing && error?.code === 'ENOENT') return { schema: 'yam.ueye-asset-manifest.v1', updated_at: '', assets: [] };
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ async function readRevisionHistory(file: string, allowMissing = true): Promise<UeyeRevisionHistory> {
265
+ try {
266
+ const data = JSON.parse(await fsp.readFile(file, 'utf8'));
267
+ if (data.schema !== 'yam.ueye-revision-history.v1' || !Array.isArray(data.revisions)) throw new Error('invalid Ueye revision history schema');
268
+ return data;
269
+ } catch (error: any) {
270
+ if (allowMissing && error?.code === 'ENOENT') return { schema: 'yam.ueye-revision-history.v1', updated_at: '', revisions: [] };
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ async function writeJsonAtomic(file: string, value: unknown) {
276
+ await fsp.mkdir(path.dirname(file), { recursive: true });
277
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
278
+ try {
279
+ await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
280
+ await fsp.rename(temporary, file);
281
+ } finally {
282
+ await fsp.rm(temporary, { force: true }).catch(() => {});
283
+ }
284
+ }
285
+
286
+ function validateOptionalHttpUrl(value: unknown, label: string) {
287
+ const text = String(value || '');
288
+ if (!text) return;
289
+ const url = new URL(text);
290
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(`${label} must use http or https`);
291
+ }
292
+
293
+ function assetWarnings(asset: UeyeAssetEntry): string[] {
294
+ const warnings: string[] = [];
295
+ if (!asset.source_url && !asset.source_page_url && !asset.operator_provided) warnings.push(`${asset.id}: source provenance is missing`);
296
+ if (!asset.license_note) warnings.push(`${asset.id}: license_note is missing; usage rights were not recorded`);
297
+ if (asset.dimensions === 'unknown') warnings.push(`${asset.id}: image dimensions are unknown`);
298
+ return warnings;
299
+ }
300
+
301
+ function safeId(value: unknown, label: string): string {
302
+ const text = String(value || '');
303
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(text)) throw new Error(`${label} must match [A-Za-z0-9._-] and be at most 64 characters`);
304
+ return text;
305
+ }
306
+
307
+ function relativePortablePath(base: string, target: string): string {
308
+ return path.relative(base, target).split(path.sep).join('/') || path.basename(target);
309
+ }
310
+
311
+ function resolveManifestFile(manifestPath: string, file: string): string {
312
+ return path.isAbsolute(file) ? file : path.resolve(path.dirname(manifestPath), file);
313
+ }
314
+
315
+ function imageDimensions(buffer: Buffer): string {
316
+ const png = pngDimensions(buffer);
317
+ if (png) return png;
318
+ const jpeg = jpegDimensions(buffer);
319
+ if (jpeg) return jpeg;
320
+ const gif = gifDimensions(buffer);
321
+ if (gif) return gif;
322
+ return 'unknown';
323
+ }
324
+
325
+ function pngDimensions(buffer: Buffer): string {
326
+ if (buffer.length < 24) return '';
327
+ if (buffer.readUInt32BE(0) !== 0x89504e47 || buffer.readUInt32BE(4) !== 0x0d0a1a0a) return '';
328
+ return `${buffer.readUInt32BE(16)}x${buffer.readUInt32BE(20)}`;
329
+ }
330
+
331
+ function gifDimensions(buffer: Buffer): string {
332
+ if (buffer.length < 10) return '';
333
+ const signature = buffer.toString('ascii', 0, 6);
334
+ if (signature !== 'GIF87a' && signature !== 'GIF89a') return '';
335
+ return `${buffer.readUInt16LE(6)}x${buffer.readUInt16LE(8)}`;
336
+ }
337
+
338
+ function jpegDimensions(buffer: Buffer): string {
339
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return '';
340
+ let offset = 2;
341
+ while (offset + 9 < buffer.length) {
342
+ if (buffer[offset] !== 0xff) return '';
343
+ const marker = buffer[offset + 1];
344
+ const length = buffer.readUInt16BE(offset + 2);
345
+ if (marker >= 0xc0 && marker <= 0xc3) return `${buffer.readUInt16BE(offset + 7)}x${buffer.readUInt16BE(offset + 5)}`;
346
+ if (length < 2) return '';
347
+ offset += 2 + length;
348
+ }
349
+ return '';
350
+ }
351
+
352
+ function uniqueStrings(values: string[]): string[] {
353
+ return [...new Set(values.filter(Boolean))];
354
+ }