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.
- package/CHANGELOG.md +23 -0
- package/COMMANDS.md +8 -1
- package/README.md +12 -0
- package/ROADMAP.md +25 -0
- package/dist/bin/yam.js +237 -62
- package/dist/lib/trust-kernel.d.ts +54 -0
- package/dist/lib/trust-kernel.js +194 -0
- package/dist/lib/ueye-artifacts.d.ts +98 -0
- package/dist/lib/ueye-artifacts.js +317 -0
- package/package.json +1 -1
- package/references/final-report.md +2 -0
- package/references/honest-completion.md +1 -0
- package/references/mission.md +16 -0
- package/references/scout-wide-scan.md +150 -0
- package/references/scout.md +9 -0
- package/references/tool-trust-layer.md +3 -0
- package/references/trust-kernel.md +3 -0
- package/references/truth-matrix.md +2 -0
- package/references/ueye-proof.md +6 -0
- package/references/ueye.md +12 -0
- package/references/verification-levels.md +2 -1
- package/skills/mission/SKILL.md +19 -4
- package/skills/scout/SKILL.md +18 -5
- package/skills/ueye/SKILL.md +11 -0
- package/src/bin/yam.ts +227 -56
- package/src/lib/trust-kernel.ts +239 -0
- package/src/lib/ueye-artifacts.ts +354 -0
- package/templates/mission-plan.md +25 -2
- package/templates/ueye-comparison.md +12 -0
- package/templates/ueye-review.md +26 -0
- package/yam.manifest.json +7 -7
package/src/lib/trust-kernel.ts
CHANGED
|
@@ -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
|
+
}
|