@mean-weasel/lineage 0.1.4 → 0.1.6

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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/lineageCli.ts
4
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "node:fs";
4
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "node:fs";
5
5
  import { homedir, platform } from "node:os";
6
- import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
6
+ import { dirname as dirname3, join as join6, resolve as resolve4 } from "node:path";
7
7
  import { spawn } from "node:child_process";
8
8
  import { fileURLToPath as fileURLToPath2 } from "node:url";
9
9
 
@@ -40,12 +40,92 @@ var mimeByExt = {
40
40
  ".webp": "image/webp"
41
41
  };
42
42
  var localReviewExts = new Set(Object.keys(mimeByExt));
43
+ var channelFromName = /\b(linkedin|meta|tiktok|youtube|x-twitter|x)\b/i;
44
+ var campaignFromPath = /\b(20\d{2}-\d{2}-[a-z0-9-]+)\b/i;
45
+ function localReviewRoot(repoRoot2) {
46
+ return join(repoRoot2, ".asset-scratch");
47
+ }
43
48
  function contentTypeFor(file) {
44
49
  return mimeByExt[extname(file).toLowerCase()] || "application/octet-stream";
45
50
  }
46
51
  function fileSha256(file) {
47
52
  return createHash("sha256").update(readFileSync(file)).digest("hex");
48
53
  }
54
+ function walkLocalReviewFiles(dir, files = []) {
55
+ if (!existsSync(dir)) return files;
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir, { withFileTypes: true });
59
+ } catch (error) {
60
+ if (error.code === "ENOENT") return files;
61
+ throw error;
62
+ }
63
+ for (const entry of entries) {
64
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "studio-uploads" || entry.name === "playwright-results" || entry.name === "lineage-demo") continue;
65
+ if (process.env.NODE_ENV !== "test" && /^vitest-/.test(entry.name)) continue;
66
+ const path = join(dir, entry.name);
67
+ if (entry.isDirectory()) walkLocalReviewFiles(path, files);
68
+ else if (entry.isFile() && localReviewExts.has(extname(entry.name).toLowerCase())) files.push(path);
69
+ }
70
+ return files;
71
+ }
72
+ function inferCampaign(relativePath) {
73
+ return campaignFromPath.exec(relativePath)?.[1] || "local-review";
74
+ }
75
+ function inferChannel(relativePath) {
76
+ const match = channelFromName.exec(relativePath);
77
+ if (!match) return "local";
78
+ return match[1].toLowerCase() === "x" ? "x-twitter" : match[1].toLowerCase();
79
+ }
80
+ function inferContentType(file) {
81
+ const mime = contentTypeFor(file);
82
+ if (mime.startsWith("image/gif")) return "gif";
83
+ if (mime.startsWith("image/")) return "image";
84
+ if (mime.startsWith("video/")) return "video";
85
+ return "other";
86
+ }
87
+ function listLocalReviewAssets(repoRoot2, project, catalog) {
88
+ const catalogChecksums = new Set(catalog.assets.map((asset) => asset.s3?.checksum_sha256).filter(Boolean));
89
+ const root = localReviewRoot(repoRoot2);
90
+ return walkLocalReviewFiles(root).flatMap((file) => {
91
+ try {
92
+ const stats = statSync(file);
93
+ const checksum = fileSha256(file);
94
+ const relativePath = relative(root, file);
95
+ return [{ checksum, file, relativePath, stats }];
96
+ } catch (error) {
97
+ if (error.code === "ENOENT") return [];
98
+ throw error;
99
+ }
100
+ }).filter((item) => !catalogChecksums.has(item.checksum)).map((item) => {
101
+ const fileSlug = basename(item.file, extname(item.file));
102
+ const channel = inferChannel(item.relativePath);
103
+ return {
104
+ asset_id: `local-${item.checksum.slice(0, 12)}`,
105
+ project,
106
+ product: project,
107
+ source: "local",
108
+ campaign: inferCampaign(item.relativePath),
109
+ channel,
110
+ audience: "local-review",
111
+ status: "planned",
112
+ content_type: inferContentType(item.file),
113
+ title: fileSlug.replace(/[-_]+/g, " "),
114
+ hook: item.relativePath,
115
+ cta: "Review before upload",
116
+ utm_content: fileSlug.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() || "local_review",
117
+ notes: "Local pre-push asset. Review and refine before uploading to S3.",
118
+ local: {
119
+ relative_path: item.relativePath,
120
+ absolute_path: item.file,
121
+ size_bytes: item.stats.size,
122
+ content_type: contentTypeFor(item.file),
123
+ checksum_sha256: item.checksum,
124
+ updated_at: item.stats.mtime.toISOString()
125
+ }
126
+ };
127
+ });
128
+ }
49
129
 
50
130
  // src/server/adapters/storage/s3StorageAdapter.ts
51
131
  function parseAssetIdFromS3Key(key) {
@@ -227,6 +307,7 @@ var defaultProduct = process.env.LINEAGE_DEFAULT_PRODUCT || defaultProject;
227
307
  var publicFallbackBucket = "lineage-demo-assets";
228
308
  var publicFallbackRegion = "us-east-1";
229
309
  var contentTypes = /* @__PURE__ */ new Set(["image", "video", "gif", "audio", "doc", "other"]);
310
+ var baseChannels = ["linkedin", "meta", "tiktok", "x-twitter", "youtube"];
230
311
  var projectNamePattern = /^[a-z0-9][a-z0-9-]*$/;
231
312
  var LineageAssetError = class extends Error {
232
313
  constructor(message, status = 400) {
@@ -360,6 +441,9 @@ function defaultFallbackCatalog() {
360
441
  project: defaultProject
361
442
  }, defaultProject);
362
443
  }
444
+ function isDefaultFallbackCatalog(catalog) {
445
+ return catalog.project === defaultProject && !existsSync3(catalogPath(defaultProject));
446
+ }
363
447
  function loadCatalog(project = defaultProject) {
364
448
  const path = catalogPath(project);
365
449
  if (existsSync3(path)) {
@@ -425,6 +509,67 @@ var storageAdapter = createS3StorageAdapter({
425
509
  saveCatalog,
426
510
  supportedContentTypes: contentTypes
427
511
  });
512
+ function uniqueSorted(values) {
513
+ return Array.from(new Set(values.filter(Boolean))).sort();
514
+ }
515
+ function facetsFor(assets) {
516
+ return {
517
+ audiences: uniqueSorted(assets.map((asset) => asset.audience)),
518
+ campaigns: uniqueSorted(assets.map((asset) => asset.campaign)),
519
+ channels: uniqueSorted([...baseChannels, ...assets.map((asset) => asset.channel)]),
520
+ contentTypes: uniqueSorted(assets.map((asset) => asset.content_type)),
521
+ placementStatuses: uniqueSorted(assets.flatMap((asset) => asset.placements?.map((placement) => placement.status) || [])),
522
+ statuses: uniqueSorted(assets.map((asset) => asset.status)),
523
+ totalSizeBytes: assets.reduce((sum, asset) => sum + (asset.s3?.size_bytes || 0), 0)
524
+ };
525
+ }
526
+ function filteredAssets(assets, options) {
527
+ const query = options.query?.trim().toLowerCase();
528
+ return assets.filter((asset) => {
529
+ if (options.status && options.status !== "all" && asset.status !== options.status) return false;
530
+ if (options.channel && options.channel !== "all" && asset.channel !== options.channel) return false;
531
+ if (options.type && options.type !== "all" && asset.content_type !== options.type) return false;
532
+ if (options.placementStatus === "not-posted" && asset.placements?.some((placement) => placement.status === "posted")) return false;
533
+ if (options.placementStatus && !["all", "not-posted"].includes(options.placementStatus) && !asset.placements?.some((placement) => placement.status === options.placementStatus)) return false;
534
+ if (options.campaign && options.campaign !== "all" && asset.campaign !== options.campaign) return false;
535
+ if (options.audience && options.audience !== "all" && asset.audience !== options.audience) return false;
536
+ if (!query) return true;
537
+ return [asset.asset_id, asset.title, asset.campaign, asset.channel, asset.audience, asset.hook, asset.cta].join(" ").toLowerCase().includes(query);
538
+ });
539
+ }
540
+ function listAssets(project = defaultProject, options = {}) {
541
+ const catalog = loadCatalog(project);
542
+ const pageSize = Math.min(Math.max(Number(options.pageSize || 10), 1), 100);
543
+ const page = Math.max(Number(options.page || 1), 1);
544
+ const localAssets = listLocalReviewAssets(repoRoot, catalog.project, catalog);
545
+ const source = options.source || "catalog";
546
+ const sourceAssets = source === "local" ? localAssets : source === "all" ? [...localAssets, ...catalog.assets] : catalog.assets;
547
+ const filtered = filteredAssets(sourceAssets, options);
548
+ const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1);
549
+ const safePage = Math.min(page, totalPages);
550
+ const start2 = (safePage - 1) * pageSize;
551
+ const pageAssets = filtered.slice(start2, start2 + pageSize);
552
+ let liveObjects = [];
553
+ let error;
554
+ if (options.includeLive && !isDefaultFallbackCatalog(catalog)) {
555
+ try {
556
+ liveObjects = storageAdapter.listLiveObjects(catalog);
557
+ } catch (err) {
558
+ error = err instanceof Error ? err.message : String(err);
559
+ }
560
+ }
561
+ return {
562
+ catalog: { project: catalog.project, product: catalog.product, default_bucket: catalog.default_bucket, default_region: catalog.default_region, asset_count: catalog.assets.length },
563
+ assets: pageAssets,
564
+ facets: facetsFor(catalog.assets),
565
+ pagination: { page: safePage, pageSize, total: filtered.length, totalPages },
566
+ liveObjects,
567
+ orphanObjects: liveObjects.filter((object) => !object.cataloged),
568
+ identity: options.includeLive && !isDefaultFallbackCatalog(catalog) ? storageAdapter.getIdentity() : void 0,
569
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
570
+ error
571
+ };
572
+ }
428
573
 
429
574
  // src/server/assetLineageDb.ts
430
575
  var require2 = createRequire(import.meta.url);
@@ -481,6 +626,44 @@ function lineageDb() {
481
626
  );
482
627
  create index if not exists edges_parent on asset_edges(project_id, parent_asset_id);
483
628
  create index if not exists edges_child on asset_edges(project_id, child_asset_id);
629
+ create table if not exists asset_attempts (
630
+ id text primary key,
631
+ project_id text not null references projects(id),
632
+ node_asset_id text not null references assets(id),
633
+ asset_id text not null references assets(id),
634
+ attempt_index integer not null check (attempt_index > 0),
635
+ source text not null check (source in ('initial', 'generated_child', 'reroll')),
636
+ prompt text,
637
+ generation_job_id text,
638
+ file_path text,
639
+ checksum_sha256 text,
640
+ created_at text not null,
641
+ promoted_at text,
642
+ is_current integer not null check (is_current in (0, 1)),
643
+ unique(project_id, node_asset_id, attempt_index),
644
+ unique(project_id, node_asset_id, asset_id, source)
645
+ );
646
+ create unique index if not exists asset_attempts_one_current
647
+ on asset_attempts(project_id, node_asset_id)
648
+ where is_current = 1;
649
+ create index if not exists asset_attempts_node_created
650
+ on asset_attempts(project_id, node_asset_id, created_at);
651
+ create table if not exists asset_reroll_requests (
652
+ id text primary key,
653
+ project_id text not null references projects(id),
654
+ root_asset_id text not null references assets(id),
655
+ node_asset_id text not null references assets(id),
656
+ status text not null check (status in ('pending', 'resolved', 'cancelled')),
657
+ requested_by text not null check (requested_by in ('human', 'agent', 'system')),
658
+ notes text,
659
+ created_at text not null,
660
+ resolved_at text
661
+ );
662
+ create unique index if not exists asset_reroll_requests_one_pending
663
+ on asset_reroll_requests(project_id, root_asset_id, node_asset_id)
664
+ where status = 'pending';
665
+ create index if not exists asset_reroll_requests_root_status
666
+ on asset_reroll_requests(project_id, root_asset_id, status, created_at);
484
667
  create table if not exists asset_reviews (
485
668
  asset_id text primary key references assets(id),
486
669
  review_state text not null check (review_state in ('unreviewed', 'approved', 'needs_revision', 'rejected', 'ignored')),
@@ -685,7 +868,7 @@ function lineageDb() {
685
868
  project_id text not null references projects(id),
686
869
  provider text not null default 'codex-handoff',
687
870
  adapter_version text not null,
688
- source_mode text not null check (source_mode in ('lineage_selection')),
871
+ source_mode text not null check (source_mode in ('lineage_selection', 'lineage_reroll')),
689
872
  root_asset_id text not null references assets(id),
690
873
  prompt text not null,
691
874
  expected_output_count integer not null check (expected_output_count > 0),
@@ -703,7 +886,7 @@ function lineageDb() {
703
886
  project_id text not null references projects(id),
704
887
  asset_id text not null references assets(id),
705
888
  root_asset_id text not null references assets(id),
706
- role text not null check (role in ('lineage_next_base', 'reference')),
889
+ role text not null check (role in ('lineage_next_base', 'reference', 'reroll_target')),
707
890
  position integer not null,
708
891
  selection_strategy text not null,
709
892
  selection_snapshot_json text not null,
@@ -1204,6 +1387,9 @@ function validateAgentClaimForWrite(fields) {
1204
1387
  }
1205
1388
  }
1206
1389
 
1390
+ // src/server/assetLineage.ts
1391
+ import { join as join5 } from "node:path";
1392
+
1207
1393
  // src/server/assetLineageSelection.ts
1208
1394
  function selectedRows(database, project, root) {
1209
1395
  return database.prepare(`
@@ -1297,6 +1483,37 @@ function activeLineageWorkspaceRoot(project) {
1297
1483
  }
1298
1484
  }
1299
1485
 
1486
+ // src/server/lineageClaimGuards.ts
1487
+ function channelOverlaps2(left, right) {
1488
+ return !left || !right || left === right;
1489
+ }
1490
+ function hasActiveLineageWorkspaceClaim(project, targetId, channel) {
1491
+ return listAgentClaims(project).claims.some((claim) => {
1492
+ if (claim.project !== project || claim.status !== "active" || claim.derived_state === "expired") return false;
1493
+ if (claim.scope_type === "lineage_workspace") return claim.target_id === targetId;
1494
+ return claim.scope_type === "project_channel" && channelOverlaps2(channel, claim.channel);
1495
+ });
1496
+ }
1497
+ function requireLineageWorkspaceClaimForWrite(fields) {
1498
+ if (!fields.confirmWrite) return;
1499
+ const targetId = lineageWorkspaceId(fields.project, fields.rootAssetId);
1500
+ if (!fields.claimToken && !hasActiveLineageWorkspaceClaim(fields.project, targetId, fields.channel)) return;
1501
+ const validation = validateAgentClaimForWrite({
1502
+ channel: fields.channel,
1503
+ claimToken: fields.claimToken,
1504
+ confirmWrite: fields.confirmWrite,
1505
+ dangerLevel: "enforce",
1506
+ project: fields.project,
1507
+ scopeType: "lineage_workspace",
1508
+ targetId,
1509
+ writeKind: fields.writeKind
1510
+ });
1511
+ if (!validation.ok) {
1512
+ const status = validation.code === "claim_required" || validation.code === "claim_token_invalid" ? 401 : 409;
1513
+ throw new AgentClaimError(validation.message, status, validation.code, validation.conflicts);
1514
+ }
1515
+ }
1516
+
1300
1517
  // src/server/assetLineage.ts
1301
1518
  var LineageError = class extends Error {
1302
1519
  constructor(message, status = 400) {
@@ -1305,6 +1522,70 @@ var LineageError = class extends Error {
1305
1522
  }
1306
1523
  status;
1307
1524
  };
1525
+ function collectAssets(project, source) {
1526
+ const first = listAssets(project, { source, page: 1, pageSize: 100 });
1527
+ const assets = [...first.assets];
1528
+ for (let page = 2; page <= first.pagination.totalPages; page += 1) {
1529
+ assets.push(...listAssets(project, { source, page, pageSize: 100 }).assets);
1530
+ }
1531
+ return assets;
1532
+ }
1533
+ function upsertProject(database, project) {
1534
+ const timestamp = nowIso();
1535
+ database.prepare(`
1536
+ insert into projects (id, product, catalog_path, created_at, updated_at)
1537
+ values (?, ?, ?, ?, ?)
1538
+ on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
1539
+ `).run(project, project, join5(repoRoot, project, "assets", "catalog.json"), timestamp, timestamp);
1540
+ }
1541
+ function upsertAsset(database, project, asset) {
1542
+ const timestamp = nowIso();
1543
+ const source = asset.source === "local" ? "local" : "catalog";
1544
+ database.prepare(`
1545
+ insert into assets (
1546
+ id, project_id, source, local_path, s3_key, checksum_sha256, media_type, title, status,
1547
+ channel, campaign, audience, size_bytes, content_type, created_at, updated_at, last_seen_at
1548
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1549
+ on conflict(id) do update set
1550
+ source = excluded.source, local_path = excluded.local_path, s3_key = excluded.s3_key,
1551
+ checksum_sha256 = excluded.checksum_sha256, media_type = excluded.media_type,
1552
+ title = excluded.title, status = excluded.status, channel = excluded.channel,
1553
+ campaign = excluded.campaign, audience = excluded.audience, size_bytes = excluded.size_bytes,
1554
+ content_type = excluded.content_type, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
1555
+ `).run(
1556
+ asset.asset_id,
1557
+ project,
1558
+ source,
1559
+ asset.local?.relative_path || null,
1560
+ asset.s3?.key || null,
1561
+ asset.local?.checksum_sha256 || asset.s3?.checksum_sha256 || null,
1562
+ asset.content_type,
1563
+ asset.title,
1564
+ asset.status,
1565
+ asset.channel || null,
1566
+ asset.campaign || null,
1567
+ asset.audience || null,
1568
+ asset.local?.size_bytes || asset.s3?.size_bytes || null,
1569
+ asset.local?.content_type || asset.s3?.content_type || null,
1570
+ timestamp,
1571
+ timestamp,
1572
+ timestamp
1573
+ );
1574
+ database.prepare(`
1575
+ insert into asset_reviews (asset_id, review_state, updated_at)
1576
+ values (?, 'unreviewed', ?)
1577
+ on conflict(asset_id) do nothing
1578
+ `).run(asset.asset_id, timestamp);
1579
+ }
1580
+ function indexLineageAssets(project = defaultProject) {
1581
+ const database = lineageDb();
1582
+ const catalog = collectAssets(project, "catalog");
1583
+ const local = collectAssets(project, "local");
1584
+ upsertProject(database, project);
1585
+ for (const asset of [...catalog, ...local]) upsertAsset(database, project, asset);
1586
+ database.close();
1587
+ return { catalog: catalog.length, local: local.length, total: catalog.length + local.length, database: lineageDbPath() };
1588
+ }
1308
1589
  function requireAsset(database, project, assetId) {
1309
1590
  const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
1310
1591
  if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
@@ -1324,6 +1605,50 @@ function rootFor(database, project, assetId) {
1324
1605
  }
1325
1606
  return assetId;
1326
1607
  }
1608
+ function assertCanonicalRoot(database, project, root) {
1609
+ requireAsset(database, project, root);
1610
+ const canonicalRoot = rootFor(database, project, root);
1611
+ if (canonicalRoot !== root) throw new LineageError(`Asset ${root} is not a lineage root`, 400);
1612
+ }
1613
+ function explicitWorkspaceRoot(database, project, assetId) {
1614
+ const row = database.prepare(`
1615
+ select root_asset_id from lineage_workspaces
1616
+ where project_id = ? and root_asset_id = ? and status != 'archived'
1617
+ `).get(project, assetId);
1618
+ return row?.root_asset_id;
1619
+ }
1620
+ function nearestWorkspaceRoot(database, project, assetId) {
1621
+ let current = assetId;
1622
+ const seen = /* @__PURE__ */ new Set();
1623
+ while (!seen.has(current)) {
1624
+ seen.add(current);
1625
+ const explicit = explicitWorkspaceRoot(database, project, current);
1626
+ if (explicit) return explicit;
1627
+ const parent = parentOf(database, project, current);
1628
+ if (!parent) return current;
1629
+ current = parent;
1630
+ }
1631
+ return assetId;
1632
+ }
1633
+ function assetChannel(database, project, assetId) {
1634
+ const row = database.prepare("select channel from assets where project_id = ? and id = ?").get(project, assetId);
1635
+ return row?.channel;
1636
+ }
1637
+ function lineageWriteClaimContext(database, project, assetId) {
1638
+ return {
1639
+ channel: assetChannel(database, project, assetId),
1640
+ rootAssetId: nearestWorkspaceRoot(database, project, assetId)
1641
+ };
1642
+ }
1643
+ function getLineageWriteClaimContext(project, assetId) {
1644
+ const database = lineageDb();
1645
+ try {
1646
+ requireAsset(database, project, assetId);
1647
+ return lineageWriteClaimContext(database, project, assetId);
1648
+ } finally {
1649
+ database.close();
1650
+ }
1651
+ }
1327
1652
  function latestSelectedRoot(database, project) {
1328
1653
  const row = database.prepare("select root_asset_id from asset_selections where project_id = ? order by selected_at desc limit 1").get(project);
1329
1654
  return row?.root_asset_id;
@@ -1341,6 +1666,75 @@ function resolveRoot(database, project, rootAssetId) {
1341
1666
  function edgeId(project, parent, child) {
1342
1667
  return `${project}:${parent}:derived_from:${child}`;
1343
1668
  }
1669
+ function rerollRequestId(project, root, node, timestamp) {
1670
+ return `${project}:${root}:reroll:${node}:${Date.parse(timestamp) || Date.now()}`;
1671
+ }
1672
+ function rowString(value) {
1673
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1674
+ }
1675
+ function rerollRequestFrom(row) {
1676
+ return {
1677
+ id: String(row.id),
1678
+ project_id: String(row.project_id),
1679
+ root_asset_id: String(row.root_asset_id),
1680
+ node_asset_id: String(row.node_asset_id),
1681
+ status: row.status,
1682
+ requested_by: row.requested_by,
1683
+ notes: rowString(row.notes),
1684
+ created_at: String(row.created_at),
1685
+ resolved_at: rowString(row.resolved_at)
1686
+ };
1687
+ }
1688
+ function attemptFrom(row) {
1689
+ return {
1690
+ id: String(row.id),
1691
+ project_id: String(row.project_id),
1692
+ node_asset_id: String(row.node_asset_id),
1693
+ asset_id: String(row.asset_id),
1694
+ attempt_index: Number(row.attempt_index),
1695
+ source: row.source,
1696
+ prompt: rowString(row.prompt),
1697
+ generation_job_id: rowString(row.generation_job_id),
1698
+ file_path: rowString(row.file_path),
1699
+ checksum_sha256: rowString(row.checksum_sha256),
1700
+ created_at: String(row.created_at),
1701
+ promoted_at: rowString(row.promoted_at),
1702
+ is_current: Boolean(Number(row.is_current))
1703
+ };
1704
+ }
1705
+ function implicitAttempt(row, isCurrent = true) {
1706
+ const createdAt = row.asset_created_at || nowIso();
1707
+ return {
1708
+ id: `${row.project}:${row.asset_id}:attempt:implicit`,
1709
+ project_id: row.project,
1710
+ node_asset_id: row.asset_id,
1711
+ asset_id: row.asset_id,
1712
+ attempt_index: 1,
1713
+ source: "initial",
1714
+ file_path: row.local_path,
1715
+ checksum_sha256: row.checksum_sha256,
1716
+ created_at: createdAt,
1717
+ promoted_at: createdAt,
1718
+ is_current: isCurrent
1719
+ };
1720
+ }
1721
+ function withImplicitAttempt(physicalAttempts, row) {
1722
+ if (physicalAttempts.some((attempt) => attempt.source === "initial")) return physicalAttempts;
1723
+ return [...physicalAttempts, implicitAttempt(row, !physicalAttempts.some((attempt) => attempt.is_current))];
1724
+ }
1725
+ function assertNodeInRoot(database, project, root, node) {
1726
+ assertCanonicalRoot(database, project, root);
1727
+ requireAsset(database, project, node);
1728
+ const nodeRoot = rootFor(database, project, node);
1729
+ if (nodeRoot !== root) throw new LineageError(`Asset ${node} is not in lineage rooted at ${root}`);
1730
+ }
1731
+ function assertAttemptAssetNotVisibleLineageNode(database, project, root, assetId) {
1732
+ const visibleNodeIds = /* @__PURE__ */ new Set([
1733
+ root,
1734
+ ...descendants(database, project, root).flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])
1735
+ ]);
1736
+ if (visibleNodeIds.has(assetId)) throw new LineageError(`Attempt asset ${assetId} is already a visible lineage node in ${root}`, 400);
1737
+ }
1344
1738
  function canPreviewLocally(mediaType, localPath) {
1345
1739
  return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
1346
1740
  }
@@ -1354,6 +1748,20 @@ function linkLineageAssets(project, fields) {
1354
1748
  requireAsset(database, project, fields.parentAssetId);
1355
1749
  requireAsset(database, project, fields.childAssetId);
1356
1750
  if (fields.parentAssetId === fields.childAssetId) throw new LineageError("Lineage link cannot point to itself");
1751
+ const claimContext = lineageWriteClaimContext(database, project, fields.parentAssetId);
1752
+ try {
1753
+ requireLineageWorkspaceClaimForWrite({
1754
+ channel: claimContext.channel,
1755
+ claimToken: fields.claimToken,
1756
+ confirmWrite: fields.confirmWrite,
1757
+ project,
1758
+ rootAssetId: claimContext.rootAssetId,
1759
+ writeKind: "lineage_link"
1760
+ });
1761
+ } catch (error) {
1762
+ database.close();
1763
+ throw error;
1764
+ }
1357
1765
  const edge = {
1358
1766
  id: edgeId(project, fields.parentAssetId, fields.childAssetId),
1359
1767
  parent_asset_id: fields.parentAssetId,
@@ -1390,18 +1798,28 @@ function descendants(database, project, root) {
1390
1798
  function getLineageSnapshot(project, assetId) {
1391
1799
  const database = lineageDb();
1392
1800
  requireAsset(database, project, assetId);
1393
- const root = rootFor(database, project, assetId);
1801
+ const root = explicitWorkspaceRoot(database, project, assetId) || rootFor(database, project, assetId);
1394
1802
  const edges = descendants(database, project, root);
1395
1803
  const ids = [.../* @__PURE__ */ new Set([root, ...edges.flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])])];
1396
1804
  const placeholders = ids.map(() => "?").join(",");
1397
1805
  const rows = database.prepare(`
1398
1806
  select a.id asset_id, a.project_id project, a.source, a.title, a.media_type, a.status, a.channel, a.campaign,
1399
1807
  a.local_path, a.s3_key, a.checksum_sha256, coalesce(r.review_state, 'unreviewed') review_state,
1400
- r.notes review_notes, l.x layout_x, l.y layout_y
1808
+ r.notes review_notes, a.created_at asset_created_at, l.x layout_x, l.y layout_y
1401
1809
  from assets a left join asset_reviews r on r.asset_id = a.id
1402
1810
  left join asset_layouts l on l.project_id = a.project_id and l.root_asset_id = ? and l.asset_id = a.id
1403
1811
  where a.project_id = ? and a.id in (${placeholders})
1404
1812
  `).all(root, project, ...ids);
1813
+ const attemptRows = ids.length > 0 ? database.prepare(`select * from asset_attempts where project_id = ? and node_asset_id in (${placeholders}) order by node_asset_id, attempt_index desc`).all(project, ...ids) : [];
1814
+ const attemptsByNode = /* @__PURE__ */ new Map();
1815
+ for (const attempt of attemptRows.map(attemptFrom)) {
1816
+ attemptsByNode.set(attempt.node_asset_id, [...attemptsByNode.get(attempt.node_asset_id) || [], attempt]);
1817
+ }
1818
+ const rerollRows = ids.length > 0 ? database.prepare(`select * from asset_reroll_requests where project_id = ? and root_asset_id = ? and status = 'pending' and node_asset_id in (${placeholders}) order by created_at`).all(project, root, ...ids) : [];
1819
+ const rerollsByNode = new Map(rerollRows.map((row) => {
1820
+ const request = rerollRequestFrom(row);
1821
+ return [request.node_asset_id, request];
1822
+ }));
1405
1823
  const selected = selectedRows(database, project, root);
1406
1824
  const childIds = new Set(edges.map((edge) => edge.parent_asset_id));
1407
1825
  const selectedIds = new Set(selected.map((row) => row.asset_id));
@@ -1414,13 +1832,19 @@ function getLineageSnapshot(project, assetId) {
1414
1832
  const selection = selections[0] || null;
1415
1833
  const nodes = rows.map((row) => {
1416
1834
  const position = typeof row.layout_x === "number" && typeof row.layout_y === "number" ? { x: row.layout_x, y: row.layout_y } : void 0;
1417
- const { layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
1835
+ const { asset_created_at: _assetCreatedAt, layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
1418
1836
  const nodeSelection = selections.find((item) => item.asset_id === row.asset_id);
1837
+ const attempts = withImplicitAttempt(attemptsByNode.get(row.asset_id) || [], row);
1838
+ const currentAttempt = attempts.find((attempt) => attempt.is_current) || attempts[0];
1839
+ const previewPath = currentAttempt?.file_path || row.local_path;
1419
1840
  return {
1420
1841
  ...node,
1842
+ attempt_count: attempts.length,
1843
+ current_attempt: currentAttempt,
1421
1844
  is_latest: !childIds.has(row.asset_id),
1422
1845
  position,
1423
- preview_url: canPreviewLocally(row.media_type, row.local_path) ? localPreviewUrl(project, row.local_path) : void 0,
1846
+ preview_url: canPreviewLocally(row.media_type, previewPath) ? localPreviewUrl(project, previewPath) : void 0,
1847
+ reroll_request: rerollsByNode.get(row.asset_id),
1424
1848
  selection_note: nodeSelection?.notes,
1425
1849
  user_selected: selectedIds.has(row.asset_id)
1426
1850
  };
@@ -1507,6 +1931,160 @@ function getLineageNextAsset(project, rootAssetId) {
1507
1931
  fetchedAt: nowIso()
1508
1932
  };
1509
1933
  }
1934
+ function listLineageRerollRequests(project, rootAssetId) {
1935
+ const database = lineageDb();
1936
+ try {
1937
+ assertCanonicalRoot(database, project, rootAssetId);
1938
+ const rows = database.prepare(`
1939
+ select * from asset_reroll_requests
1940
+ where project_id = ? and root_asset_id = ? and status = 'pending'
1941
+ order by created_at, id
1942
+ `).all(project, rootAssetId);
1943
+ return { project, root_asset_id: rootAssetId, requests: rows.map(rerollRequestFrom), fetchedAt: nowIso() };
1944
+ } finally {
1945
+ database.close();
1946
+ }
1947
+ }
1948
+ function recordLineageRerollAttempt(project, fields) {
1949
+ const database = lineageDb();
1950
+ try {
1951
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
1952
+ requireAsset(database, project, fields.assetId);
1953
+ assertAttemptAssetNotVisibleLineageNode(database, project, fields.rootAssetId, fields.assetId);
1954
+ const timestamp = nowIso();
1955
+ const maxRow = database.prepare("select max(attempt_index) max_index from asset_attempts where project_id = ? and node_asset_id = ?").get(project, fields.nodeAssetId);
1956
+ const attemptIndex = Number(maxRow.max_index || 1) + 1;
1957
+ const attempt = {
1958
+ id: `${project}:${fields.nodeAssetId}:attempt:${attemptIndex}`,
1959
+ project_id: project,
1960
+ node_asset_id: fields.nodeAssetId,
1961
+ asset_id: fields.assetId,
1962
+ attempt_index: attemptIndex,
1963
+ source: "reroll",
1964
+ prompt: fields.prompt,
1965
+ generation_job_id: fields.generationJobId,
1966
+ file_path: fields.filePath,
1967
+ checksum_sha256: fields.checksumSha256,
1968
+ created_at: timestamp,
1969
+ promoted_at: timestamp,
1970
+ is_current: true
1971
+ };
1972
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, attempt };
1973
+ database.exec("BEGIN IMMEDIATE");
1974
+ try {
1975
+ database.prepare("update asset_attempts set is_current = 0 where project_id = ? and node_asset_id = ?").run(project, fields.nodeAssetId);
1976
+ database.prepare(`
1977
+ insert into asset_attempts (
1978
+ id, project_id, node_asset_id, asset_id, attempt_index, source, prompt, generation_job_id,
1979
+ file_path, checksum_sha256, created_at, promoted_at, is_current
1980
+ ) values (?, ?, ?, ?, ?, 'reroll', ?, ?, ?, ?, ?, ?, 1)
1981
+ `).run(
1982
+ attempt.id,
1983
+ project,
1984
+ fields.nodeAssetId,
1985
+ fields.assetId,
1986
+ attempt.attempt_index,
1987
+ fields.prompt,
1988
+ fields.generationJobId,
1989
+ fields.filePath,
1990
+ fields.checksumSha256,
1991
+ timestamp,
1992
+ timestamp
1993
+ );
1994
+ database.prepare(`
1995
+ update asset_reroll_requests
1996
+ set status = 'resolved', resolved_at = ?
1997
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
1998
+ `).run(timestamp, project, fields.rootAssetId, fields.nodeAssetId);
1999
+ database.prepare(`
2000
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2001
+ values (?, 'unreviewed', ?, null, null, ?)
2002
+ on conflict(asset_id) do update set
2003
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2004
+ ignored_at = excluded.ignored_at, updated_at = excluded.updated_at
2005
+ `).run(fields.nodeAssetId, timestamp, timestamp);
2006
+ database.exec("COMMIT");
2007
+ } catch (error) {
2008
+ database.exec("ROLLBACK");
2009
+ throw error;
2010
+ }
2011
+ return { ok: true, attempt };
2012
+ } finally {
2013
+ database.close();
2014
+ }
2015
+ }
2016
+ function markLineageRerollRequest(project, fields) {
2017
+ const database = lineageDb();
2018
+ try {
2019
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2020
+ const existing = database.prepare(`
2021
+ select * from asset_reroll_requests
2022
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
2023
+ order by created_at desc limit 1
2024
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
2025
+ const timestamp = nowIso();
2026
+ const request = existing ? {
2027
+ ...rerollRequestFrom(existing),
2028
+ notes: fields.notes || rerollRequestFrom(existing).notes,
2029
+ requested_by: fields.requestedBy || rerollRequestFrom(existing).requested_by
2030
+ } : {
2031
+ id: rerollRequestId(project, fields.rootAssetId, fields.nodeAssetId, timestamp),
2032
+ project_id: project,
2033
+ root_asset_id: fields.rootAssetId,
2034
+ node_asset_id: fields.nodeAssetId,
2035
+ status: "pending",
2036
+ requested_by: fields.requestedBy || "human",
2037
+ notes: fields.notes,
2038
+ created_at: timestamp
2039
+ };
2040
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
2041
+ if (existing) {
2042
+ database.prepare(`
2043
+ update asset_reroll_requests
2044
+ set requested_by = ?, notes = ?
2045
+ where id = ?
2046
+ `).run(request.requested_by, request.notes || null, request.id);
2047
+ } else {
2048
+ database.prepare(`
2049
+ insert into asset_reroll_requests (id, project_id, root_asset_id, node_asset_id, status, requested_by, notes, created_at, resolved_at)
2050
+ values (?, ?, ?, ?, 'pending', ?, ?, ?, null)
2051
+ `).run(request.id, project, fields.rootAssetId, fields.nodeAssetId, request.requested_by, request.notes || null, request.created_at);
2052
+ }
2053
+ database.prepare(`
2054
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2055
+ values (?, 'needs_revision', ?, null, ?, ?)
2056
+ on conflict(asset_id) do update set
2057
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2058
+ ignored_at = excluded.ignored_at, notes = coalesce(excluded.notes, asset_reviews.notes), updated_at = excluded.updated_at
2059
+ `).run(fields.nodeAssetId, timestamp, request.notes || null, timestamp);
2060
+ return { ok: true, request: rerollRequestFrom(database.prepare("select * from asset_reroll_requests where id = ?").get(request.id)) };
2061
+ } finally {
2062
+ database.close();
2063
+ }
2064
+ }
2065
+ function clearLineageRerollRequest(project, fields) {
2066
+ const database = lineageDb();
2067
+ try {
2068
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2069
+ const existing = database.prepare(`
2070
+ select * from asset_reroll_requests
2071
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
2072
+ order by created_at desc limit 1
2073
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
2074
+ if (!existing) throw new LineageError(`No pending re-roll request for ${fields.nodeAssetId}`, 404);
2075
+ const timestamp = nowIso();
2076
+ const request = { ...rerollRequestFrom(existing), status: "cancelled", resolved_at: timestamp };
2077
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
2078
+ database.prepare(`
2079
+ update asset_reroll_requests
2080
+ set status = 'cancelled', resolved_at = ?
2081
+ where id = ?
2082
+ `).run(timestamp, request.id);
2083
+ return { ok: true, request };
2084
+ } finally {
2085
+ database.close();
2086
+ }
2087
+ }
1510
2088
 
1511
2089
  // src/server/assetLineageHandoff.ts
1512
2090
  var publicPackageCommand = "npx @mean-weasel/lineage";
@@ -1566,14 +2144,15 @@ function linkSelectedLineageChild(project, fields) {
1566
2144
  const next = getLineageNextAsset(project, fields.rootAssetId);
1567
2145
  if (!next.next_asset) throw new LineageError("Cannot link child until a next base is selected or unambiguous");
1568
2146
  if (fields.confirmWrite) {
2147
+ const claimContext = getLineageWriteClaimContext(project, next.next_asset.asset_id);
1569
2148
  const validation = validateAgentClaimForWrite({
1570
- channel: next.next_asset.channel,
2149
+ channel: claimContext.channel,
1571
2150
  claimToken: fields.claimToken,
1572
2151
  confirmWrite: fields.confirmWrite,
1573
2152
  dangerLevel: "enforce",
1574
2153
  project,
1575
2154
  scopeType: "lineage_workspace",
1576
- targetId: lineageWorkspaceId(project, next.root_asset_id),
2155
+ targetId: lineageWorkspaceId(project, claimContext.rootAssetId),
1577
2156
  writeKind: "link_child"
1578
2157
  });
1579
2158
  if (!validation.ok) throw new AgentClaimError(validation.message, validation.code === "claim_required" ? 401 : 403, validation.code, validation.conflicts);
@@ -1594,17 +2173,307 @@ function linkSelectedLineageChild(project, fields) {
1594
2173
  };
1595
2174
  }
1596
2175
 
2176
+ // src/server/generationReceipts.ts
2177
+ import { existsSync as existsSync4, realpathSync, statSync as statSync3 } from "node:fs";
2178
+ import { relative as relative2, resolve as resolve3 } from "node:path";
2179
+ import { randomUUID } from "node:crypto";
2180
+ var adapterVersion = "generation-receipts-v1";
2181
+ var provider = "codex-handoff";
2182
+ var GenerationReceiptError = class extends Error {
2183
+ constructor(message, status = 400) {
2184
+ super(message);
2185
+ this.status = status;
2186
+ }
2187
+ status;
2188
+ };
2189
+ function jobId() {
2190
+ return `gen-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
2191
+ }
2192
+ function quote(value) {
2193
+ return JSON.stringify(value);
2194
+ }
2195
+ function parseJson(value, fallback) {
2196
+ if (!value) return fallback;
2197
+ return JSON.parse(value);
2198
+ }
2199
+ function buildRerollHandoff(project, id, prompt, rootAssetId, target, request) {
2200
+ const importCommand = `npx lineage reroll import --project ${quote(project)} --job-id ${quote(id)} --file <.asset-scratch-file> --confirm-write --json`;
2201
+ return {
2202
+ schema_version: "lineage.generation_handoff.v1",
2203
+ provider,
2204
+ project,
2205
+ job_id: id,
2206
+ prompt,
2207
+ expected_output_count: 1,
2208
+ lineage: {
2209
+ root_asset_id: rootAssetId,
2210
+ parent_asset_id: target.asset_id,
2211
+ selection_strategy: "reroll_request",
2212
+ parent_title: target.title,
2213
+ parent_local_path: target.local_path,
2214
+ parent_s3_key: target.s3_key
2215
+ },
2216
+ instructions: [
2217
+ "Use Codex image generation outside Lineage server code.",
2218
+ "Write the regenerated output file under .asset-scratch before import.",
2219
+ "Do not call live provider APIs from the CLI or server.",
2220
+ "Import exactly one output with reroll import, not link-child or generate image import.",
2221
+ `Resolve re-roll request ${request.id}; do not create a visible lineage child edge.`
2222
+ ],
2223
+ import_command: importCommand,
2224
+ guardrails: { live_generation: false, external_services: false, output_root: ".asset-scratch", confirm_write_required: true }
2225
+ };
2226
+ }
2227
+ function receiptFrom(row) {
2228
+ return {
2229
+ id: String(row.id),
2230
+ job_id: String(row.job_id),
2231
+ receipt_type: row.receipt_type,
2232
+ status: row.status,
2233
+ command: String(row.command),
2234
+ payload: parseJson(String(row.payload_json), null),
2235
+ created_at: String(row.created_at)
2236
+ };
2237
+ }
2238
+ function outputFrom(row) {
2239
+ return {
2240
+ id: String(row.id),
2241
+ job_id: String(row.job_id),
2242
+ project_id: String(row.project_id),
2243
+ output_index: Number(row.output_index),
2244
+ file_path: String(row.file_path),
2245
+ checksum_sha256: String(row.checksum_sha256),
2246
+ size_bytes: Number(row.size_bytes),
2247
+ content_type: String(row.content_type),
2248
+ imported_asset_id: String(row.imported_asset_id),
2249
+ parent_asset_id: String(row.parent_asset_id),
2250
+ imported_at: String(row.imported_at)
2251
+ };
2252
+ }
2253
+ function loadGenerationJob(database, project, id) {
2254
+ const row = database.prepare("select * from generation_jobs where project_id = ? and id = ?").get(project, id);
2255
+ if (!row) throw new GenerationReceiptError(`Unknown generation job: ${id}`, 404);
2256
+ const inputRows = database.prepare("select * from generation_job_inputs where job_id = ? order by position").all(id);
2257
+ const inputs = inputRows.map((input) => ({
2258
+ id: String(input.id),
2259
+ job_id: String(input.job_id),
2260
+ project_id: String(input.project_id),
2261
+ asset_id: String(input.asset_id),
2262
+ root_asset_id: String(input.root_asset_id),
2263
+ role: input.role,
2264
+ position: Number(input.position),
2265
+ selection_strategy: String(input.selection_strategy),
2266
+ selection_snapshot: parseJson(String(input.selection_snapshot_json), {})
2267
+ }));
2268
+ const outputs = database.prepare("select * from generation_job_outputs where job_id = ? order by output_index").all(id).map(outputFrom);
2269
+ const receipts = database.prepare("select * from generation_job_receipts where job_id = ? order by created_at, id").all(id).map(receiptFrom);
2270
+ return {
2271
+ id: String(row.id),
2272
+ project_id: String(row.project_id),
2273
+ provider: row.provider,
2274
+ adapter_version: String(row.adapter_version),
2275
+ source_mode: String(row.source_mode),
2276
+ root_asset_id: String(row.root_asset_id),
2277
+ prompt: String(row.prompt),
2278
+ expected_output_count: Number(row.expected_output_count),
2279
+ status: row.status,
2280
+ output_dir: typeof row.output_dir === "string" ? row.output_dir : void 0,
2281
+ handoff: parseJson(String(row.handoff_json), {}),
2282
+ created_at: String(row.created_at),
2283
+ updated_at: String(row.updated_at),
2284
+ imported_at: typeof row.imported_at === "string" ? row.imported_at : void 0,
2285
+ inputs,
2286
+ outputs,
2287
+ receipts
2288
+ };
2289
+ }
2290
+ function insertReceipt(database, id, type, command, payload) {
2291
+ database.prepare(`
2292
+ insert into generation_job_receipts (id, job_id, receipt_type, status, command, payload_json, created_at)
2293
+ values (?, ?, ?, 'ok', ?, ?, ?)
2294
+ `).run(`${id}:receipt:${type}:${Date.now()}`, id, type, command, JSON.stringify(payload), nowIso());
2295
+ }
2296
+ function planImageReroll(project = defaultProject, fields) {
2297
+ const prompt = fields.prompt.trim();
2298
+ if (!prompt) throw new GenerationReceiptError("Missing --prompt");
2299
+ if (!fields.rootAssetId) throw new GenerationReceiptError("Missing --root");
2300
+ if (!fields.targetAssetId) throw new GenerationReceiptError("Missing --target");
2301
+ const snapshot = getLineageSnapshot(project, fields.rootAssetId);
2302
+ const target = snapshot.nodes.find((node) => node.asset_id === fields.targetAssetId);
2303
+ if (!target) throw new GenerationReceiptError(`Re-roll target is not in lineage: ${fields.targetAssetId}`, 404);
2304
+ const request = listLineageRerollRequests(project, snapshot.root_asset_id).requests.find((item) => item.node_asset_id === fields.targetAssetId);
2305
+ if (!request) throw new GenerationReceiptError(`No pending re-roll request for ${fields.targetAssetId}`);
2306
+ const id = jobId();
2307
+ const timestamp = nowIso();
2308
+ const handoff = buildRerollHandoff(project, id, prompt, snapshot.root_asset_id, target, request);
2309
+ const input = {
2310
+ id: `${id}:input:0`,
2311
+ job_id: id,
2312
+ project_id: project,
2313
+ asset_id: target.asset_id,
2314
+ root_asset_id: snapshot.root_asset_id,
2315
+ role: "reroll_target",
2316
+ position: 0,
2317
+ selection_strategy: "reroll_request",
2318
+ selection_snapshot: {
2319
+ project,
2320
+ root_asset_id: snapshot.root_asset_id,
2321
+ strategy: "selected",
2322
+ selection_mode: "single",
2323
+ recommended_action: "evolve_variations",
2324
+ reason: "user_selected",
2325
+ next_asset: target,
2326
+ next_assets: [target],
2327
+ latest: snapshot.latest,
2328
+ selected: [target.asset_id],
2329
+ selection: null,
2330
+ selections: [],
2331
+ candidates: snapshot.nodes,
2332
+ warnings: ["Re-roll target: import output as an attempt, not a lineage child."],
2333
+ fetchedAt: timestamp
2334
+ }
2335
+ };
2336
+ const preview = {
2337
+ id,
2338
+ project_id: project,
2339
+ provider,
2340
+ adapter_version: adapterVersion,
2341
+ source_mode: "lineage_reroll",
2342
+ root_asset_id: snapshot.root_asset_id,
2343
+ prompt,
2344
+ expected_output_count: 1,
2345
+ status: "planned",
2346
+ output_dir: ".asset-scratch",
2347
+ handoff,
2348
+ created_at: timestamp,
2349
+ updated_at: timestamp,
2350
+ inputs: [input],
2351
+ outputs: [],
2352
+ receipts: [{
2353
+ id: `${id}:receipt:plan:preview`,
2354
+ job_id: id,
2355
+ receipt_type: "plan",
2356
+ status: "ok",
2357
+ command: "reroll plan",
2358
+ payload: { prompt, expected_output_count: 1, lineage: handoff.lineage, reroll_request_id: request.id },
2359
+ created_at: timestamp
2360
+ }]
2361
+ };
2362
+ if (fields.dryRun) return { ok: true, command: "generate image plan", project, dryRun: true, wouldWrite: true, job: preview };
2363
+ const database = lineageDb();
2364
+ try {
2365
+ database.exec("BEGIN IMMEDIATE");
2366
+ try {
2367
+ database.prepare(`
2368
+ insert into generation_jobs (
2369
+ id, project_id, provider, adapter_version, source_mode, root_asset_id, prompt,
2370
+ expected_output_count, status, output_dir, handoff_json, created_at, updated_at
2371
+ ) values (?, ?, ?, ?, 'lineage_reroll', ?, ?, 1, 'planned', ?, ?, ?, ?)
2372
+ `).run(id, project, provider, adapterVersion, snapshot.root_asset_id, prompt, ".asset-scratch", JSON.stringify(handoff), timestamp, timestamp);
2373
+ database.prepare("insert into generation_job_inputs (id, job_id, project_id, asset_id, root_asset_id, role, position, selection_strategy, selection_snapshot_json) values (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(input.id, id, project, input.asset_id, input.root_asset_id, input.role, input.position, input.selection_strategy, JSON.stringify(input.selection_snapshot));
2374
+ insertReceipt(database, id, "plan", "reroll plan", preview.receipts[0].payload);
2375
+ database.exec("COMMIT");
2376
+ } catch (error) {
2377
+ database.exec("ROLLBACK");
2378
+ throw error;
2379
+ }
2380
+ return { ok: true, command: "generate image plan", project, job: loadGenerationJob(database, project, id) };
2381
+ } finally {
2382
+ database.close();
2383
+ }
2384
+ }
2385
+ function isPathInside(child, parent) {
2386
+ const rel = relative2(parent, child);
2387
+ return Boolean(rel) && !rel.startsWith("..") && !rel.startsWith("/");
2388
+ }
2389
+ function resolveScratchFile(file) {
2390
+ const scratchRoot = resolve3(repoRoot, ".asset-scratch");
2391
+ const candidate = file.startsWith(".asset-scratch/") || resolve3(file).startsWith(scratchRoot) ? resolve3(repoRoot, file) : resolve3(scratchRoot, file);
2392
+ if (!isPathInside(candidate, scratchRoot)) throw new GenerationReceiptError(`Import file must be under .asset-scratch: ${file}`);
2393
+ if (!existsSync4(candidate)) throw new GenerationReceiptError(`Missing import file: ${file}`, 404);
2394
+ const realScratchRoot = realpathSync(scratchRoot);
2395
+ const realCandidate = realpathSync(candidate);
2396
+ if (!isPathInside(realCandidate, realScratchRoot)) throw new GenerationReceiptError(`Import file must be under .asset-scratch: ${file}`);
2397
+ const stats = statSync3(candidate);
2398
+ if (!stats.isFile()) throw new GenerationReceiptError(`Import path is not a file: ${file}`);
2399
+ const checksum = fileSha256(candidate);
2400
+ return {
2401
+ relativePath: relative2(scratchRoot, candidate),
2402
+ checksum,
2403
+ size: stats.size,
2404
+ contentType: contentTypeFor(candidate),
2405
+ assetId: `local-${checksum.slice(0, 12)}`
2406
+ };
2407
+ }
2408
+ function importImageRerollOutput(project = defaultProject, fields) {
2409
+ if (!fields.jobId) throw new GenerationReceiptError("Missing --job-id");
2410
+ if (!fields.confirmWrite) throw new GenerationReceiptError("Generation import requires --confirm-write");
2411
+ const database = lineageDb();
2412
+ let job;
2413
+ try {
2414
+ job = loadGenerationJob(database, project, fields.jobId);
2415
+ } finally {
2416
+ database.close();
2417
+ }
2418
+ if (job.status !== "planned") throw new GenerationReceiptError(`Generation job is not importable from status: ${job.status}`);
2419
+ if (job.source_mode !== "lineage_reroll") throw new GenerationReceiptError(`Generation job is not a re-roll job: ${job.source_mode}`);
2420
+ const target = job.inputs.filter((input) => input.role === "reroll_target");
2421
+ if (target.length !== 1) throw new GenerationReceiptError("Re-roll import requires exactly one reroll_target input");
2422
+ const resolved = resolveScratchFile(fields.file);
2423
+ indexLineageAssets(project);
2424
+ const writeDb = lineageDb();
2425
+ try {
2426
+ const timestamp = nowIso();
2427
+ writeDb.exec("BEGIN IMMEDIATE");
2428
+ try {
2429
+ const assetRow = writeDb.prepare("select id from assets where project_id = ? and id = ?").get(project, resolved.assetId);
2430
+ if (!assetRow) throw new GenerationReceiptError(`Indexed local asset was not found: ${resolved.relativePath}`);
2431
+ const outputId = `${fields.jobId}:output:0`;
2432
+ writeDb.prepare(`insert into generation_job_outputs (
2433
+ id, job_id, project_id, output_index, file_path, checksum_sha256, size_bytes, content_type, imported_asset_id, parent_asset_id, imported_at
2434
+ ) values (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`).run(outputId, fields.jobId, project, resolved.relativePath, resolved.checksum, resolved.size, resolved.contentType, resolved.assetId, target[0].asset_id, timestamp);
2435
+ writeDb.prepare(`
2436
+ update generation_jobs
2437
+ set status = 'imported', imported_at = ?, updated_at = ?
2438
+ where project_id = ? and id = ?
2439
+ `).run(timestamp, timestamp, project, fields.jobId);
2440
+ insertReceipt(writeDb, fields.jobId, "import", "reroll import", {
2441
+ file: { output_index: 0, file_path: resolved.relativePath, imported_asset_id: resolved.assetId, parent_asset_id: target[0].asset_id },
2442
+ reroll: { root_asset_id: job.root_asset_id, node_asset_id: target[0].asset_id }
2443
+ });
2444
+ writeDb.exec("COMMIT");
2445
+ } catch (error) {
2446
+ writeDb.exec("ROLLBACK");
2447
+ throw error;
2448
+ }
2449
+ recordLineageRerollAttempt(project, {
2450
+ rootAssetId: job.root_asset_id,
2451
+ nodeAssetId: target[0].asset_id,
2452
+ assetId: resolved.assetId,
2453
+ prompt: job.prompt,
2454
+ generationJobId: fields.jobId,
2455
+ filePath: resolved.relativePath,
2456
+ checksumSha256: resolved.checksum,
2457
+ confirmWrite: true
2458
+ });
2459
+ const importedJob = loadGenerationJob(writeDb, project, fields.jobId);
2460
+ return { ok: true, command: "generate image import", project, job: importedJob, imported: importedJob.outputs };
2461
+ } finally {
2462
+ writeDb.close();
2463
+ }
2464
+ }
2465
+
1597
2466
  // src/cli/lineageCli.ts
1598
2467
  var signalExitCodes = {
1599
2468
  SIGINT: 130,
1600
2469
  SIGTERM: 143
1601
2470
  };
1602
2471
  function packageRoot() {
1603
- return resolve3(dirname3(fileURLToPath2(import.meta.url)), "..", "..");
2472
+ return resolve4(dirname3(fileURLToPath2(import.meta.url)), "..", "..");
1604
2473
  }
1605
2474
  function packageVersion() {
1606
2475
  try {
1607
- const packageInfo = JSON.parse(readFileSync3(join5(packageRoot(), "package.json"), "utf8"));
2476
+ const packageInfo = JSON.parse(readFileSync3(join6(packageRoot(), "package.json"), "utf8"));
1608
2477
  return packageInfo.version || "0.0.0";
1609
2478
  } catch {
1610
2479
  return "0.0.0";
@@ -1612,9 +2481,9 @@ function packageVersion() {
1612
2481
  }
1613
2482
  function dataRoot(displayName) {
1614
2483
  if (process.env.LINEAGE_HOME) return process.env.LINEAGE_HOME;
1615
- if (platform() === "darwin") return join5(homedir(), "Library", "Application Support", displayName);
1616
- if (platform() === "win32") return join5(process.env.APPDATA || join5(homedir(), "AppData", "Roaming"), displayName);
1617
- return join5(process.env.XDG_DATA_HOME || join5(homedir(), ".local", "share"), displayName.toLowerCase().replace(/\s+/g, "-"));
2484
+ if (platform() === "darwin") return join6(homedir(), "Library", "Application Support", displayName);
2485
+ if (platform() === "win32") return join6(process.env.APPDATA || join6(homedir(), "AppData", "Roaming"), displayName);
2486
+ return join6(process.env.XDG_DATA_HOME || join6(homedir(), ".local", "share"), displayName.toLowerCase().replace(/\s+/g, "-"));
1618
2487
  }
1619
2488
  function readOption(args, name) {
1620
2489
  const prefix = `${name}=`;
@@ -1632,7 +2501,7 @@ function resolveStartOptions(config, args) {
1632
2501
  throw new Error(`Invalid port: ${rawPort}`);
1633
2502
  }
1634
2503
  return {
1635
- dbPath: readOption(args, "--db") || process.env.LINEAGE_DB || join5(runtimeDir, `${config.binName}.sqlite`),
2504
+ dbPath: readOption(args, "--db") || process.env.LINEAGE_DB || join6(runtimeDir, `${config.binName}.sqlite`),
1636
2505
  host: readOption(args, "--host") || process.env.HOST || config.defaultHost,
1637
2506
  json: args.includes("--json"),
1638
2507
  open: args.includes("--open"),
@@ -1648,7 +2517,13 @@ Usage:
1648
2517
  ${config.binName} brief [--project <project>] [--root <asset-id>] [--db <path>] [--json]
1649
2518
  ${config.binName} inspect --asset-id <asset-id> [--project <project>] [--db <path>] [--json]
1650
2519
  ${config.binName} link-child --root <asset-id> --child <asset-id> [--project <project>] [--claim-token <claim-id.secret>] [--confirm-write] [--db <path>] [--json]
2520
+ ${config.binName} reroll list --root <asset-id> [--project <project>] [--db <path>] [--json]
2521
+ ${config.binName} reroll mark --root <asset-id> --target <asset-id> [--notes <text>] [--requested-by agent|human|system] [--project <project>] [--confirm-write] [--db <path>] [--json]
2522
+ ${config.binName} reroll cancel --root <asset-id> --target <asset-id> [--project <project>] [--confirm-write] [--db <path>] [--json]
2523
+ ${config.binName} reroll plan --root <asset-id> --target <asset-id> --prompt <text> [--project <project>] [--db <path>] [--json]
2524
+ ${config.binName} reroll import --job-id <job-id> --file <scratch-file> --confirm-write [--project <project>] [--db <path>] [--json]
1651
2525
  ${config.binName} agent claim --project <project> --scope <scope> --target <target-id> --agent-name <name> [--channel <channel>] [--ttl 20m] [--json]
2526
+ ${config.binName} agent graph --root <asset-id> [--project <project>] [--db <path>] [--json]
1652
2527
  ${config.binName} agent status [--project <project>] [--json]
1653
2528
  ${config.binName} agent inspect --claim <claim-id> [--project <project>] [--json]
1654
2529
  ${config.binName} agent heartbeat --claim-token <claim-id.secret> [--json]
@@ -1710,8 +2585,63 @@ function runLineageDataCommand(command, args) {
1710
2585
  rootAssetId: options.rootAssetId || options.assetId
1711
2586
  });
1712
2587
  }
2588
+ if (command === "reroll") {
2589
+ const subcommand = positionalArgs(args)[0] || "";
2590
+ if (subcommand === "list") {
2591
+ if (!options.rootAssetId) throw new Error("lineage reroll list requires --root");
2592
+ return listLineageRerollRequests(options.project, options.rootAssetId);
2593
+ }
2594
+ if (subcommand === "mark") {
2595
+ const targetAssetId = readOption(args, "--target");
2596
+ const requestedBy = rerollRequestedBy(readOption(args, "--requested-by") || "agent");
2597
+ if (!options.rootAssetId) throw new Error("lineage reroll mark requires --root");
2598
+ if (!targetAssetId) throw new Error("lineage reroll mark requires --target");
2599
+ return markLineageRerollRequest(options.project, {
2600
+ rootAssetId: options.rootAssetId,
2601
+ nodeAssetId: targetAssetId,
2602
+ notes: readOption(args, "--notes"),
2603
+ requestedBy,
2604
+ confirmWrite: options.confirmWrite
2605
+ });
2606
+ }
2607
+ if (subcommand === "cancel") {
2608
+ const targetAssetId = readOption(args, "--target");
2609
+ if (!options.rootAssetId) throw new Error("lineage reroll cancel requires --root");
2610
+ if (!targetAssetId) throw new Error("lineage reroll cancel requires --target");
2611
+ return clearLineageRerollRequest(options.project, {
2612
+ rootAssetId: options.rootAssetId,
2613
+ nodeAssetId: targetAssetId,
2614
+ confirmWrite: options.confirmWrite
2615
+ });
2616
+ }
2617
+ if (subcommand === "plan") {
2618
+ const targetAssetId = readOption(args, "--target");
2619
+ const prompt = readOption(args, "--prompt");
2620
+ if (!options.rootAssetId) throw new Error("lineage reroll plan requires --root");
2621
+ if (!targetAssetId) throw new Error("lineage reroll plan requires --target");
2622
+ if (!prompt) throw new Error("lineage reroll plan requires --prompt");
2623
+ return planImageReroll(options.project, {
2624
+ rootAssetId: options.rootAssetId,
2625
+ targetAssetId,
2626
+ prompt,
2627
+ dryRun: args.includes("--dry-run")
2628
+ });
2629
+ }
2630
+ if (subcommand === "import") {
2631
+ const jobId2 = readOption(args, "--job-id");
2632
+ const file = readOption(args, "--file");
2633
+ if (!jobId2) throw new Error("lineage reroll import requires --job-id");
2634
+ if (!file) throw new Error("lineage reroll import requires --file");
2635
+ return importImageRerollOutput(options.project, { jobId: jobId2, file, confirmWrite: options.confirmWrite });
2636
+ }
2637
+ throw new Error(`Unknown reroll command: ${subcommand}`);
2638
+ }
1713
2639
  throw new Error(`Unknown command: ${command}`);
1714
2640
  }
2641
+ function rerollRequestedBy(value) {
2642
+ if (value === "agent" || value === "human" || value === "system") return value;
2643
+ throw new Error(`Invalid re-roll requester: ${value}`);
2644
+ }
1715
2645
  function printDataResult(command, result, json) {
1716
2646
  if (json) {
1717
2647
  console.log(JSON.stringify(result, null, 2));
@@ -1740,6 +2670,24 @@ function printDataResult(command, result, json) {
1740
2670
  console.log(link.message || `${link.dryRun ? "Dry run: " : ""}Link ${link.edge?.child_asset_id || "child"} from ${link.edge?.parent_asset_id || "parent"}`);
1741
2671
  return;
1742
2672
  }
2673
+ if (command === "reroll" && result && typeof result === "object") {
2674
+ if ("requests" in result) {
2675
+ const listed = result;
2676
+ console.log(`${listed.requests.length} pending re-roll target(s)`);
2677
+ for (const request of listed.requests) console.log(`${request.node_asset_id}${request.notes ? `: ${request.notes}` : ""}`);
2678
+ return;
2679
+ }
2680
+ if ("job" in result) {
2681
+ const planned = result;
2682
+ console.log(planned.imported ? `Imported re-roll for ${planned.job?.id || "job"}` : `Planned re-roll ${planned.job?.id || "job"}`);
2683
+ return;
2684
+ }
2685
+ if ("request" in result) {
2686
+ const mutation = result;
2687
+ console.log(`${mutation.dryRun ? "Dry run: " : ""}Re-roll ${mutation.request?.status || "request"} for ${mutation.request?.node_asset_id || "target"}`);
2688
+ return;
2689
+ }
2690
+ }
1743
2691
  console.log(String(result));
1744
2692
  }
1745
2693
  function runLineageAgentCommand(command, args) {
@@ -1765,6 +2713,11 @@ function runLineageAgentCommand(command, args) {
1765
2713
  });
1766
2714
  }
1767
2715
  if (command === "status") return listAgentClaims(project);
2716
+ if (command === "graph") {
2717
+ const rootAssetId = readOption(args, "--root") || readOption(args, "--asset-id") || positionalArgs(args)[0];
2718
+ if (!rootAssetId) throw new Error("lineage agent graph requires --root");
2719
+ return getLineageSnapshot(project, rootAssetId);
2720
+ }
1768
2721
  if (command === "inspect") {
1769
2722
  if (!claimId) throw new Error("lineage agent inspect requires --claim");
1770
2723
  return inspectAgentClaim(claimId, project);
@@ -1821,8 +2774,37 @@ function printAgentResult(command, result, json) {
1821
2774
  console.log(`${inspected.claim?.id || "claim"} ${inspected.claim?.status || "unknown"} ${inspected.claim?.target_id || ""}`.trim());
1822
2775
  return;
1823
2776
  }
2777
+ if (command === "graph" && result && typeof result === "object" && "nodes" in result) {
2778
+ console.log(formatAgentGraphDigest(result));
2779
+ return;
2780
+ }
1824
2781
  console.log(String(result));
1825
2782
  }
2783
+ function formatAgentGraphDigest(snapshot) {
2784
+ const lines = [];
2785
+ const titleFor = (assetId) => {
2786
+ const node = snapshot.nodes.find((item) => item.asset_id === assetId);
2787
+ return node?.title ? `${node.title} (${assetId})` : assetId;
2788
+ };
2789
+ const root = snapshot.nodes.find((node) => node.asset_id === snapshot.root_asset_id);
2790
+ lines.push(`Lineage graph: ${root?.title || snapshot.root_asset_id}`);
2791
+ lines.push(`Root: ${snapshot.root_asset_id}`);
2792
+ lines.push(`Active: ${titleFor(snapshot.active_asset_id)}`);
2793
+ lines.push(`Nodes: ${snapshot.nodes.length} Edges: ${snapshot.edges.length}`);
2794
+ const selected = snapshot.selected || [];
2795
+ if (selected.length > 0) {
2796
+ lines.push("Next variation:");
2797
+ for (const assetId of selected) lines.push(`- ${titleFor(assetId)}`);
2798
+ }
2799
+ const latest = snapshot.latest || snapshot.nodes.filter((node) => node.is_latest).map((node) => node.asset_id);
2800
+ if (latest.length > 0) {
2801
+ lines.push("Latest leaves:");
2802
+ for (const assetId of latest) lines.push(`- ${titleFor(assetId)}`);
2803
+ }
2804
+ lines.push("Edges:");
2805
+ for (const edge of snapshot.edges) lines.push(`- ${titleFor(edge.parent_asset_id)} -> ${titleFor(edge.child_asset_id)}`);
2806
+ return lines.join("\n");
2807
+ }
1826
2808
  function start(config, args) {
1827
2809
  let options;
1828
2810
  const json = args.includes("--json");
@@ -1834,8 +2816,8 @@ function start(config, args) {
1834
2816
  else console.error(`${config.binName}: ${message}`);
1835
2817
  process.exit(1);
1836
2818
  }
1837
- const serverPath = join5(packageRoot(), "dist", "server.js");
1838
- if (!existsSync4(serverPath)) {
2819
+ const serverPath = join6(packageRoot(), "dist", "server.js");
2820
+ if (!existsSync5(serverPath)) {
1839
2821
  const message = `Missing bundled server at ${serverPath}. Run npm run build before using ${config.binName} start from a source checkout.`;
1840
2822
  if (options.json) console.error(JSON.stringify({ ok: false, error: message }, null, 2));
1841
2823
  else console.error(`${config.binName}: ${message}`);
@@ -1889,7 +2871,7 @@ function runLineageCli(config, args = process.argv.slice(2)) {
1889
2871
  start(config, normalizedArgs.slice(1));
1890
2872
  return;
1891
2873
  }
1892
- if (command === "next" || command === "brief" || command === "inspect" || command === "link-child") {
2874
+ if (command === "next" || command === "brief" || command === "inspect" || command === "link-child" || command === "reroll") {
1893
2875
  const commandArgs = normalizedArgs.slice(1);
1894
2876
  const json2 = commandArgs.includes("--json");
1895
2877
  try {