From ab9e1c84abb1f686bbcb3740e6a6154b2c4a8d31 Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Wed, 20 May 2026 13:55:26 +0300 Subject: [PATCH 1/3] feat(figma-phase-e): drift detection and mapping cache reuse T2F.10: LoadPreviousMeta looks up most recent matching run by docID + figmaFileKey T2F.10: LoadMappings reads mappings.json from a previous run artifact T2F.10: generateChunksWithFigma checks Figma version before re-fetching T2F.10: version unchanged => reuse stored mappings (skips GetNodes + screenshot DL) T2F.10: version changed => warn + full re-fetch T2F.10: RunMetadata.FigmaVersion stored after each figma fetch T2F.10: low-confidence/fallback mappings flagged with status: unresolved --- docs/implementation-log.md | 7 +- internal/artifacts/manager.go | 146 +++++++++++++++++++++----- internal/orchestrator/orchestrator.go | 51 +++++++-- internal/source/mapping/resolver.go | 7 ++ 4 files changed, 176 insertions(+), 35 deletions(-) diff --git a/docs/implementation-log.md b/docs/implementation-log.md index f4a4220..d137886 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -253,9 +253,12 @@ _Parent: `feat/figma-phase-d-cli`_ **Tasks:** T2F.10 -**Summary:** _(to be filled by agent)_ +**Summary:** Implemented drift detection and mapping cache reuse for Figma-backed runs. `RunMetadata` and `RunIndexEntry` gained a `FigmaVersion` field, and three new methods were added to `artifacts.Manager`: `LoadPreviousMeta` (scans `runs.jsonl` in reverse to find the most recent successful run with a matching DocID and Figma file key), `LoadMappings` (reads `extraction/mappings.json` from a prior run), and `UpdateRunFigmaVersion` (patches the current run's `metadata.json` after a fresh Figma fetch). In `generateChunksWithFigma`, a `GetMeta` call is now made before any other Figma API calls; if the version is unchanged versus the previous run, the stored mappings are reused and `GetNodes`/screenshot downloads are skipped; if changed, a warning is logged and a full re-fetch proceeds. `Resolver.Build` was hardened with a post-process normalization step that explicitly marks any chunk with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"` as `Status: "unresolved"`, preventing silent promotion of low-quality mappings. -**Files changed:** _(to be filled by agent)_ +**Files changed:** +- `internal/artifacts/manager.go`: Added `FigmaVersion` field to `RunMetadata` and `RunIndexEntry`; added `LoadPreviousMeta`, `LoadMappings`, and `UpdateRunFigmaVersion` methods; added `bufio` import for JSONL scanning. +- `internal/orchestrator/orchestrator.go`: Rewrote `generateChunksWithFigma` to call `GetMeta` first for drift detection, consult `LoadPreviousMeta`/`LoadMappings` for cache reuse, log version changes as warnings, and call `UpdateRunFigmaVersion` after each fresh Figma fetch. +- `internal/source/mapping/resolver.go`: Added post-process normalization loop in `Build` that sets `Status: "unresolved"` for any mapping with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"`. --- diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index 17070eb..07aae0f 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -1,6 +1,7 @@ package artifacts import ( + "bufio" "crypto/rand" "encoding/hex" "encoding/json" @@ -15,28 +16,30 @@ import ( // RunMetadata is written to metadata.json inside each run directory. type RunMetadata struct { - RunID string `json:"run_id"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at,omitempty"` - Status string `json:"status"` // "in_progress", "success", "failed" - DocID string `json:"doc_id"` - FigmaURL string `json:"figma_url,omitempty"` - Mode string `json:"mode"` // "execute", "dry-run", "issue" - ChunkCount int `json:"chunk_count"` - ArtifactDir string `json:"artifact_dir"` + RunID string `json:"run_id"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at,omitempty"` + Status string `json:"status"` // "in_progress", "success", "failed" + DocID string `json:"doc_id"` + FigmaURL string `json:"figma_url,omitempty"` + FigmaVersion string `json:"figma_version,omitempty"` + Mode string `json:"mode"` // "execute", "dry-run", "issue" + ChunkCount int `json:"chunk_count"` + ArtifactDir string `json:"artifact_dir"` } // RunIndexEntry is one line in runs.jsonl. type RunIndexEntry struct { - RunID string `json:"run_id"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at,omitempty"` - Status string `json:"status"` - DocID string `json:"doc_id"` - FigmaURL string `json:"figma_url,omitempty"` - Mode string `json:"mode"` - ChunkCount int `json:"chunk_count"` - ArtifactDir string `json:"artifact_dir"` + RunID string `json:"run_id"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at,omitempty"` + Status string `json:"status"` + DocID string `json:"doc_id"` + FigmaURL string `json:"figma_url,omitempty"` + FigmaVersion string `json:"figma_version,omitempty"` + Mode string `json:"mode"` + ChunkCount int `json:"chunk_count"` + ArtifactDir string `json:"artifact_dir"` } // Manager handles append-only artifact storage for Bauer runs. @@ -117,15 +120,16 @@ func (m *Manager) CompleteRun(runID string, status string, chunkCount int) error // Append to runs.jsonl entry := RunIndexEntry{ - RunID: meta.RunID, - StartedAt: meta.StartedAt, - CompletedAt: meta.CompletedAt, - Status: status, - DocID: meta.DocID, - FigmaURL: meta.FigmaURL, - Mode: meta.Mode, - ChunkCount: chunkCount, - ArtifactDir: meta.ArtifactDir, + RunID: meta.RunID, + StartedAt: meta.StartedAt, + CompletedAt: meta.CompletedAt, + Status: status, + DocID: meta.DocID, + FigmaURL: meta.FigmaURL, + FigmaVersion: meta.FigmaVersion, + Mode: meta.Mode, + ChunkCount: chunkCount, + ArtifactDir: meta.ArtifactDir, } line, err := json.Marshal(entry) if err != nil { @@ -200,6 +204,94 @@ func (m *Manager) Base() string { return m.base } +// LoadPreviousMeta returns the RunMetadata from the most recent completed run +// that matches the given (docID, figmaFileKey) pair, or nil if none exists. +// It reads runs.jsonl in reverse order (last entry = most recent) to find the match. +func (m *Manager) LoadPreviousMeta(docID, figmaFileKey string) *RunMetadata { + indexPath := filepath.Join(m.base, "runs.jsonl") + f, err := os.Open(indexPath) + if err != nil { + return nil + } + defer f.Close() + + // Collect matching entries (in file order; last = most recent). + var matched []RunIndexEntry + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + var entry RunIndexEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue + } + if entry.Status != "success" { + continue + } + if entry.DocID != docID { + continue + } + // Match figmaFileKey against the stored FigmaURL. + if entry.FigmaURL == "" { + continue + } + ref, err := figma.ParseLink(entry.FigmaURL) + if err != nil || ref.FileKey != figmaFileKey { + continue + } + matched = append(matched, entry) + } + if len(matched) == 0 { + return nil + } + + // Last entry is the most recent run. + best := matched[len(matched)-1] + metaPath := filepath.Join(m.base, best.RunID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return nil + } + var meta RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil + } + return &meta +} + +// LoadMappings returns the resolved chunks from a previous run's mappings.json. +// Returns nil if the artifact does not exist or cannot be parsed. +func (m *Manager) LoadMappings(runID string) []mapping.ResolvedChunk { + path := filepath.Join(m.base, runID, "extraction", "mappings.json") + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var chunks []mapping.ResolvedChunk + if err := json.Unmarshal(data, &chunks); err != nil { + return nil + } + return chunks +} + +// UpdateRunFigmaVersion patches the run's metadata.json with the given figma version string. +// This is called after a successful Figma fetch so future runs can use it for drift detection. +func (m *Manager) UpdateRunFigmaVersion(runID, figmaVersion string) error { + metaPath := filepath.Join(m.base, runID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return fmt.Errorf("read metadata for version update: %w", err) + } + var meta RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("parse metadata for version update: %w", err) + } + meta.FigmaVersion = figmaVersion + return m.writeJSON(runID, "metadata.json", meta) +} + func (m *Manager) writeJSON(runID, relPath string, data any) error { b, err := json.MarshalIndent(data, "", " ") if err != nil { diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 726f2d6..e7ba443 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -298,6 +298,44 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( figmaClient := figma.NewClient(cfg.FigmaToken) + // Drift detection: check whether the Figma file version has changed since last run. + currentMeta, err := figmaClient.GetMeta(ctx, figmaRef.FileKey) + if err != nil { + return nil, fmt.Errorf("fetching figma metadata for drift check: %w", err) + } + + suggestedURL := "" + if bundle.Document.Metadata != nil { + suggestedURL = bundle.Document.Metadata.SuggestedUrl + } + + prevRunMeta := o.arts.LoadPreviousMeta(cfg.DocID, figmaRef.FileKey) + if prevRunMeta != nil && prevRunMeta.FigmaVersion == currentMeta.Version { + // Version unchanged: reuse stored mappings and skip re-fetch. + slog.Info("Figma version unchanged; reusing stored mappings", + slog.String("version", currentMeta.Version), + slog.String("prev_run_id", prevRunMeta.RunID), + ) + resolvedChunks := o.arts.LoadMappings(prevRunMeta.RunID) + if resolvedChunks != nil { + return engine.RenderChunksFromResolved( + bundle.Document.DocumentTitle, + suggestedURL, + cfg.FigmaURL, + resolvedChunks, + cfg.ChunkSize, + cfg.OutputDir, + ) + } + slog.Warn("Previous mappings could not be loaded; proceeding with full re-fetch", + slog.String("prev_run_id", prevRunMeta.RunID)) + } else if prevRunMeta != nil { + slog.Warn("Figma version changed; re-fetching all design data", + slog.String("prev_version", prevRunMeta.FigmaVersion), + slog.String("current_version", currentMeta.Version), + ) + } + // Determine screenshot directory (inside the artifact run when available). screenshotDir := "" if runID != "" { @@ -319,6 +357,13 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( slog.Int("comments", len(design.Comments)), ) + // Store the figma version so future runs can use it for drift detection. + if runID != "" { + if verr := o.arts.UpdateRunFigmaVersion(runID, design.Version); verr != nil { + slog.Warn("Failed to update figma version in run metadata", slog.String("error", verr.Error())) + } + } + // Persist figma artifacts. if runID != "" { if werr := o.arts.WriteFigmaExtraction(runID, design); werr != nil { @@ -340,12 +385,6 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( } } - // Generate figma-aware prompt files. - suggestedURL := "" - if bundle.Document.Metadata != nil { - suggestedURL = bundle.Document.Metadata.SuggestedUrl - } - return engine.RenderChunksFromResolved( bundle.Document.DocumentTitle, suggestedURL, diff --git a/internal/source/mapping/resolver.go b/internal/source/mapping/resolver.go index 77a1ed1..2837cac 100644 --- a/internal/source/mapping/resolver.go +++ b/internal/source/mapping/resolver.go @@ -32,6 +32,13 @@ func (r *Resolver) Build( chunks[i].Comments = r.commentsForAnchors(anchors, design) } } + // Enforce: low-confidence and fallback/none mappings must never be silently promoted. + for i := range chunks { + m := &chunks[i].Mapping + if m.Confidence < 0.5 || m.Method == "fallback" || m.Method == "none" { + m.Status = "unresolved" + } + } return chunks } From f1582e36db79d89982108cb34bf4795842e074ca Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Tue, 26 May 2026 11:22:23 +0300 Subject: [PATCH 2/3] fix: address PR review comments (cache persistence, comment accuracy) --- docs/implementation-log.md | 2 +- internal/artifacts/manager.go | 2 +- internal/orchestrator/orchestrator.go | 7 +++++++ internal/source/mapping/resolver.go | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/implementation-log.md b/docs/implementation-log.md index d137886..913421b 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -253,7 +253,7 @@ _Parent: `feat/figma-phase-d-cli`_ **Tasks:** T2F.10 -**Summary:** Implemented drift detection and mapping cache reuse for Figma-backed runs. `RunMetadata` and `RunIndexEntry` gained a `FigmaVersion` field, and three new methods were added to `artifacts.Manager`: `LoadPreviousMeta` (scans `runs.jsonl` in reverse to find the most recent successful run with a matching DocID and Figma file key), `LoadMappings` (reads `extraction/mappings.json` from a prior run), and `UpdateRunFigmaVersion` (patches the current run's `metadata.json` after a fresh Figma fetch). In `generateChunksWithFigma`, a `GetMeta` call is now made before any other Figma API calls; if the version is unchanged versus the previous run, the stored mappings are reused and `GetNodes`/screenshot downloads are skipped; if changed, a warning is logged and a full re-fetch proceeds. `Resolver.Build` was hardened with a post-process normalization step that explicitly marks any chunk with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"` as `Status: "unresolved"`, preventing silent promotion of low-quality mappings. +**Summary:** Implemented drift detection and mapping cache reuse for Figma-backed runs. `RunMetadata` and `RunIndexEntry` gained a `FigmaVersion` field, and three new methods were added to `artifacts.Manager`: `LoadPreviousMeta` (scans `runs.jsonl` forward and selects the last matching entry for the most recent successful run with a matching DocID and Figma file key), `LoadMappings` (reads `extraction/mappings.json` from a prior run), and `UpdateRunFigmaVersion` (patches the current run's `metadata.json` after a fresh Figma fetch). In `generateChunksWithFigma`, a `GetMeta` call is now made before any other Figma API calls; if the version is unchanged versus the previous run, the stored mappings are reused and `GetNodes`/screenshot downloads are skipped; if changed, a warning is logged and a full re-fetch proceeds. `Resolver.Build` was hardened with a post-process normalization step that explicitly marks any chunk with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"` as `Status: "unresolved"`, preventing silent promotion of low-quality mappings. **Files changed:** - `internal/artifacts/manager.go`: Added `FigmaVersion` field to `RunMetadata` and `RunIndexEntry`; added `LoadPreviousMeta`, `LoadMappings`, and `UpdateRunFigmaVersion` methods; added `bufio` import for JSONL scanning. diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index 07aae0f..4109bc9 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -206,7 +206,7 @@ func (m *Manager) Base() string { // LoadPreviousMeta returns the RunMetadata from the most recent completed run // that matches the given (docID, figmaFileKey) pair, or nil if none exists. -// It reads runs.jsonl in reverse order (last entry = most recent) to find the match. +// It scans runs.jsonl forward and returns metadata from the last matching entry. func (m *Manager) LoadPreviousMeta(docID, figmaFileKey string) *RunMetadata { indexPath := filepath.Join(m.base, "runs.jsonl") f, err := os.Open(indexPath) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index e7ba443..fe4020c 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -318,6 +318,13 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( ) resolvedChunks := o.arts.LoadMappings(prevRunMeta.RunID) if resolvedChunks != nil { + // Persist reused mappings into the current run so it is self-contained. + if runID != "" { + if err := o.arts.WriteMappings(runID, resolvedChunks); err != nil { + slog.Warn("Failed to persist reused mappings to current run", + slog.String("error", err.Error())) + } + } return engine.RenderChunksFromResolved( bundle.Document.DocumentTitle, suggestedURL, diff --git a/internal/source/mapping/resolver.go b/internal/source/mapping/resolver.go index 2837cac..95ae216 100644 --- a/internal/source/mapping/resolver.go +++ b/internal/source/mapping/resolver.go @@ -32,7 +32,8 @@ func (r *Resolver) Build( chunks[i].Comments = r.commentsForAnchors(anchors, design) } } - // Enforce: low-confidence and fallback/none mappings must never be silently promoted. + // Post-process: low-confidence, fallback, and unmatched ("none") mappings are + // promoted to status "unresolved" so they are never silently treated as healthy. for i := range chunks { m := &chunks[i].Mapping if m.Confidence < 0.5 || m.Method == "fallback" || m.Method == "none" { From 8b8a7b7232f10040ef3ae5977f2c8a9338064995 Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Tue, 26 May 2026 12:13:15 +0300 Subject: [PATCH 3/3] fix: address second-round PR review comments --- internal/artifacts/manager.go | 27 ++++++++++++++++----------- internal/orchestrator/orchestrator.go | 4 ++++ internal/source/mapping/resolver.go | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index 4109bc9..2b0d3e0 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -247,18 +247,23 @@ func (m *Manager) LoadPreviousMeta(docID, figmaFileKey string) *RunMetadata { return nil } - // Last entry is the most recent run. - best := matched[len(matched)-1] - metaPath := filepath.Join(m.base, best.RunID, "metadata.json") - data, err := os.ReadFile(metaPath) - if err != nil { - return nil - } - var meta RunMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return nil + // Iterate backwards to find the most recent run with a non-empty FigmaVersion. + for i := len(matched) - 1; i >= 0; i-- { + metaPath := filepath.Join(m.base, matched[i].RunID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + continue + } + if meta.FigmaVersion == "" { + continue + } + return &meta } - return &meta + return nil } // LoadMappings returns the resolved chunks from a previous run's mappings.json. diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index fe4020c..c7bf364 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -324,6 +324,10 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( slog.Warn("Failed to persist reused mappings to current run", slog.String("error", err.Error())) } + if err := o.arts.UpdateRunFigmaVersion(runID, currentMeta.Version); err != nil { + slog.Warn("Failed to record figma version on cache-hit run", + slog.String("error", err.Error())) + } } return engine.RenderChunksFromResolved( bundle.Document.DocumentTitle, diff --git a/internal/source/mapping/resolver.go b/internal/source/mapping/resolver.go index 95ae216..d2d4b28 100644 --- a/internal/source/mapping/resolver.go +++ b/internal/source/mapping/resolver.go @@ -22,7 +22,7 @@ func (r *Resolver) Build( for i, group := range groups { chunks[i] = ResolvedChunk{ Locations: []gdocs.LocationGroupedSuggestions{group}, - Mapping: MappingMetadata{Method: "none", Confidence: 0, Status: "none"}, + Mapping: MappingMetadata{Method: "none", Confidence: 0, Status: "unresolved"}, } if design != nil { anchors, meta := r.resolveAnchor(group, design)