@sanity/ailf 0.1.33 → 0.2.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.
Files changed (32) hide show
  1. package/LICENSE +21 -0
  2. package/config/airbyte/ai_literacy_framework.connector.yaml +6 -0
  3. package/config/bigquery/views/reports.sql +1 -0
  4. package/dist/_vendor/ailf-core/examples/index.d.ts +10 -20
  5. package/dist/_vendor/ailf-core/examples/index.js +10 -20
  6. package/dist/_vendor/ailf-core/ports/context.d.ts +1 -1
  7. package/dist/_vendor/ailf-core/ports/task-source.d.ts +2 -0
  8. package/dist/_vendor/ailf-core/types/index.d.ts +19 -1
  9. package/dist/_vendor/ailf-tasks/schemas.d.ts +12 -0
  10. package/dist/_vendor/ailf-tasks/schemas.js +4 -0
  11. package/dist/adapters/task-sources/content-lake-task-source.js +9 -1
  12. package/dist/adapters/task-sources/repo-task-source.js +19 -4
  13. package/dist/commands/calculate-scores.js +5 -1
  14. package/dist/commands/publish.js +3 -0
  15. package/dist/orchestration/steps/calculate-scores-step.js +18 -19
  16. package/dist/orchestration/steps/publish-report-step.js +12 -1
  17. package/dist/pipeline/calculate-scores.d.ts +6 -1
  18. package/dist/pipeline/calculate-scores.js +5 -13
  19. package/dist/pipeline/compare.js +12 -5
  20. package/dist/pipeline/generate-configs.js +4 -9
  21. package/dist/pipeline/mirror-repo-tasks.d.ts +77 -0
  22. package/dist/pipeline/mirror-repo-tasks.js +141 -27
  23. package/dist/pipeline/pr-comment.js +5 -2
  24. package/dist/pipeline/release-report.js +4 -0
  25. package/dist/pipeline/report-title.d.ts +66 -0
  26. package/dist/pipeline/report-title.js +118 -0
  27. package/dist/report-store.d.ts +5 -1
  28. package/dist/report-store.js +31 -2
  29. package/dist/sinks/bigquery/index.d.ts +1 -0
  30. package/dist/sinks/bigquery/index.js +1 -0
  31. package/dist/sinks/slack/format.js +10 -0
  32. package/package.json +23 -23
@@ -63,8 +63,9 @@ export class CalculateScoresStep {
63
63
  catch {
64
64
  // Non-fatal — proceed without source metadata
65
65
  }
66
+ let belowCritical = [];
66
67
  try {
67
- calculateAndWriteScores({
68
+ const result = calculateAndWriteScores({
68
69
  allowedOrigins: ctx.config.allowedOrigins,
69
70
  mode: ctx.config.mode,
70
71
  resolvedSource,
@@ -75,25 +76,14 @@ export class CalculateScoresStep {
75
76
  searchMode: ctx.config.searchMode,
76
77
  source: ctx.config.source,
77
78
  });
79
+ belowCritical = result.belowCritical;
78
80
  }
79
81
  catch (err) {
80
- const code = err !== null && typeof err === "object" && "status" in err
81
- ? err.status
82
- : undefined;
83
- if (code !== undefined && code !== 1) {
84
- return {
85
- durationMs: Date.now() - start,
86
- error: `calculate-scores failed with exit code ${code}`,
87
- status: "failed",
88
- };
89
- }
90
- if (code === undefined) {
91
- return {
92
- durationMs: Date.now() - start,
93
- error: `calculate-scores failed: ${err instanceof Error ? err.message : String(err)}`,
94
- status: "failed",
95
- };
96
- }
82
+ return {
83
+ durationMs: Date.now() - start,
84
+ error: `calculate-scores failed: ${err instanceof Error ? err.message : String(err)}`,
85
+ status: "failed",
86
+ };
97
87
  }
98
88
  // Postcondition: score summary exists and is valid
99
89
  const summaryIssues = checkScoreSummaryValid(ctx.config.rootDir);
@@ -105,10 +95,19 @@ export class CalculateScoresStep {
105
95
  status: "failed",
106
96
  };
107
97
  }
98
+ // Propagate belowCritical into pipeline state for downstream consumers
99
+ // (e.g., orchestrator reporting, publish step metadata).
100
+ // This is informational — the pipeline continues to run subsequent steps.
101
+ if (belowCritical.length > 0) {
102
+ state.belowCritical = belowCritical;
103
+ }
104
+ const criticalSuffix = belowCritical.length > 0
105
+ ? ` (${belowCritical.length} area(s) below critical threshold: ${belowCritical.join(", ")})`
106
+ : "";
108
107
  return {
109
108
  durationMs: Date.now() - start,
110
109
  status: "success",
111
- summary: "Scores calculated and summary written",
110
+ summary: `Scores calculated and summary written${criticalSuffix}`,
112
111
  };
113
112
  }
114
113
  cacheInputs(ctx) {
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
14
14
  import { resolve } from "path";
15
15
  import { checkScoreSummaryValid } from "../../pipeline/checks.js";
16
16
  import { buildProvenance, } from "../../pipeline/provenance.js";
17
+ import { generateReportTitle } from "../../pipeline/report-title.js";
17
18
  import { generateReportId } from "../../report-store.js";
18
19
  import { withRetry } from "../../sinks/retry.js";
19
20
  export class PublishReportStep {
@@ -82,8 +83,16 @@ export class PublishReportStep {
82
83
  const durationMs = Date.now() - this.pipelineStart;
83
84
  // Auto-compare against most recent comparable baseline.
84
85
  // Returns the comparison + baseline report ID for lineage tracking.
86
+ //
87
+ // When release auto-scope is active, the current experiment only covers
88
+ // a subset of areas. We pass the evaluated area set so autoCompare can
89
+ // scope the baseline to match — preventing mismatched areas from
90
+ // polluting the overall delta.
91
+ const evaluatedAreas = state.releaseAutoScope
92
+ ? new Set(summary.scores.map((s) => s.feature))
93
+ : undefined;
85
94
  const autoCompareResult = ctx.reportStore
86
- ? (await ctx.reportStore.autoCompare(summary, provenance, now))
95
+ ? (await ctx.reportStore.autoCompare(summary, provenance, now, evaluatedAreas))
87
96
  : null;
88
97
  const comparison = autoCompareResult?.comparison ?? null;
89
98
  // Record which report we compared against in lineage
@@ -93,6 +102,7 @@ export class PublishReportStep {
93
102
  comparedAgainst: autoCompareResult.baselineReportId,
94
103
  };
95
104
  }
105
+ const title = generateReportTitle({ provenance });
96
106
  const report = {
97
107
  comparison: comparison ?? undefined,
98
108
  completedAt: now,
@@ -101,6 +111,7 @@ export class PublishReportStep {
101
111
  provenance,
102
112
  summary,
103
113
  tag: this.options.publishTag ?? ctx.config.publishTag,
114
+ title,
104
115
  };
105
116
  // Share reportId with downstream steps (CallbackStep + orchestrator job update)
106
117
  state.reportId = reportId;
@@ -99,4 +99,9 @@ export interface CalculateScoresOptions {
99
99
  /** Documentation source name */
100
100
  source?: string;
101
101
  }
102
- export declare function calculateAndWriteScores(options: CalculateScoresOptions): void;
102
+ /** Result from calculateAndWriteScores — replaces process.exit() calls. */
103
+ export interface CalculateScoresResult {
104
+ /** Feature areas that scored below the critical threshold (40). */
105
+ belowCritical: string[];
106
+ }
107
+ export declare function calculateAndWriteScores(options: CalculateScoresOptions): CalculateScoresResult;
@@ -674,15 +674,10 @@ export function calculateAndWriteScores(options) {
674
674
  const resultsIssues = checkResultsExist(ROOT, baselineResultsPath);
675
675
  const resultsErrors = resultsIssues.filter((i) => i.severity === "error");
676
676
  if (resultsErrors.length > 0) {
677
- console.error("❌ Results validation failed:");
678
- for (const e of resultsErrors) {
679
- console.error(` ERROR: ${e.message}`);
680
- if (e.path) {
681
- console.error(` at ${e.path}`);
682
- }
683
- }
684
- console.error("\nRun 'pnpm eval' first to generate results, then 'pnpm calculate-scores'.");
685
- process.exit(1);
677
+ const details = resultsErrors
678
+ .map((e) => (e.path ? `${e.message} (at ${e.path})` : e.message))
679
+ .join("; ");
680
+ throw new Error(`Results validation failed: ${details}. Run 'pnpm eval' first to generate results.`);
686
681
  }
687
682
  console.log(`Reading results from: ${baselineResultsPath}`);
688
683
  if (source) {
@@ -750,10 +745,7 @@ export function calculateAndWriteScores(options) {
750
745
  writeFileSync(join(outDir, "grader-judgments.json"), JSON.stringify(judgments, null, 2));
751
746
  console.log(`Grader judgments written to results/latest/grader-judgments.json (${judgments.length} judgments)`);
752
747
  }
753
- // Exit with non-zero if any area below critical threshold
754
- if (summary.belowCritical.length > 0) {
755
- process.exit(1);
756
- }
748
+ return { belowCritical: summary.belowCritical };
757
749
  }
758
750
  function printPerModelReport(perModel) {
759
751
  console.log("-".repeat(80));
@@ -51,11 +51,14 @@ export function compare(baseline, experiment, options) {
51
51
  // Identify mismatched areas
52
52
  const onlyInBaseline = [...baselineAreas].filter((a) => !experimentAreas.has(a));
53
53
  const onlyInExperiment = [...experimentAreas].filter((a) => !baselineAreas.has(a));
54
- // Build per-area deltas
54
+ // Build per-area deltas.
55
+ // Areas present in only one summary get change: "not-evaluated" — comparing
56
+ // against a missing score is meaningless (it would produce false ±100 deltas).
57
+ const mismatchedSet = new Set([...onlyInBaseline, ...onlyInExperiment]);
55
58
  const areas = [...allAreas]
56
59
  .sort()
57
- .map((area) => buildAreaDelta(area, findScore(baseline.scores, area), findScore(experiment.scores, area), threshold));
58
- // Classify areas
60
+ .map((area) => buildAreaDelta(area, findScore(baseline.scores, area), findScore(experiment.scores, area), threshold, mismatchedSet.has(area)));
61
+ // Classify areas — mismatched areas are excluded from all three buckets
59
62
  const improved = areas
60
63
  .filter((a) => a.change === "improved")
61
64
  .map((a) => a.area);
@@ -65,6 +68,9 @@ export function compare(baseline, experiment, options) {
65
68
  const unchanged = areas
66
69
  .filter((a) => a.change === "unchanged")
67
70
  .map((a) => a.area);
71
+ const notEvaluated = areas
72
+ .filter((a) => a.change === "not-evaluated")
73
+ .map((a) => a.area);
68
74
  // Per-area deltas as a record
69
75
  const perArea = {};
70
76
  for (const a of areas) {
@@ -128,12 +134,13 @@ export function compare(baseline, experiment, options) {
128
134
  },
129
135
  noiseThreshold: threshold,
130
136
  noiseThresholdEmpirical: empirical,
137
+ notEvaluated,
131
138
  regressed,
132
139
  unchanged,
133
140
  };
134
141
  }
135
142
  /** Build an AreaDelta from baseline and experiment scores for a single area */
136
- function buildAreaDelta(area, baselineScore, experimentScore, threshold) {
143
+ function buildAreaDelta(area, baselineScore, experimentScore, threshold, isMismatched = false) {
137
144
  const b = baselineScore;
138
145
  const e = experimentScore;
139
146
  const bTotal = b?.totalScore ?? 0;
@@ -174,7 +181,7 @@ function buildAreaDelta(area, baselineScore, experimentScore, threshold) {
174
181
  area,
175
182
  baseline: bTotal,
176
183
  ceilingDelta: eCeiling - bCeiling,
177
- change: classifyChange(delta, threshold),
184
+ change: isMismatched ? "not-evaluated" : classifyChange(delta, threshold),
178
185
  delta,
179
186
  dimensions: {
180
187
  codeCorrectness: {
@@ -264,15 +264,10 @@ export function generateConfigs(options) {
264
264
  const modelIssues = validateModelsYaml(rootDir);
265
265
  const modelErrors = modelIssues.filter((i) => i.severity === "error");
266
266
  if (modelErrors.length > 0) {
267
- console.error("❌ config/models.yaml validation failed:");
268
- for (const e of modelErrors) {
269
- console.error(` ERROR: ${e.message}`);
270
- if (e.path) {
271
- console.error(` at ${e.path}`);
272
- }
273
- }
274
- console.error("\nFix config/models.yaml before generating configs. Run 'pnpm validate' for details.");
275
- process.exit(1);
267
+ const details = modelErrors
268
+ .map((e) => (e.path ? `${e.message} (at ${e.path})` : e.message))
269
+ .join("; ");
270
+ throw new Error(`config/models.yaml validation failed: ${details}. Run 'pnpm validate' for details.`);
276
271
  }
277
272
  console.log("Loading config/models.yaml...");
278
273
  const models = loadModels(rootDir);
@@ -24,6 +24,15 @@ export interface MirrorOptions {
24
24
  /** If true, log what would be done without writing */
25
25
  dryRun?: boolean;
26
26
  }
27
+ /** Authorship info extracted from git context or GitHub Actions environment. */
28
+ export interface GitAuthor {
29
+ /** Git commit author name (e.g., "Jordan Smith") */
30
+ gitName?: string;
31
+ /** Git commit author email (e.g., "jordan@example.com") */
32
+ gitEmail?: string;
33
+ /** GitHub username (from GITHUB_ACTOR or event payload) */
34
+ githubUsername?: string;
35
+ }
27
36
  export interface GitContext {
28
37
  /** Full repo identifier (e.g., "sanity-io/visual-editing") */
29
38
  repo: string;
@@ -35,6 +44,8 @@ export interface GitContext {
35
44
  branch: string;
36
45
  /** HEAD commit SHA */
37
46
  commitSha: string;
47
+ /** Author of the current commit/trigger */
48
+ author: GitAuthor;
38
49
  }
39
50
  export interface MirrorResult {
40
51
  /** Total tasks processed */
@@ -84,3 +95,69 @@ export declare function mirrorDocId(owner: string, repo: string, taskId: string)
84
95
  * that's not mirrored.
85
96
  */
86
97
  export declare function computeTaskHash(task: TaskDefinition): string;
98
+ /** @internal Exported for testing — not part of the public API. */
99
+ export declare function buildMirrorDocument(task: TaskDefinition, opts: {
100
+ contentHash: string;
101
+ docId: string;
102
+ /** Existing author from the current mirror document (write-once preservation) */
103
+ existingAuthor?: GitAuthor;
104
+ git: GitContext;
105
+ slugToDocId: Map<string, string>;
106
+ }): {
107
+ baseline?: {
108
+ rubric?: "full" | "abbreviated" | "none" | undefined;
109
+ enabled?: boolean | undefined;
110
+ } | undefined;
111
+ _id: string;
112
+ _type: string;
113
+ ownership: string;
114
+ status: "active" | "draft" | "paused" | "archived";
115
+ assert: Record<string, unknown>[];
116
+ canonicalDocs: ({
117
+ _key: string;
118
+ reason: string;
119
+ } | {
120
+ refType: string;
121
+ path: string;
122
+ _key: string;
123
+ reason: string;
124
+ } | {
125
+ doc?: {
126
+ _ref: string;
127
+ _type: string;
128
+ } | undefined;
129
+ docId?: string | undefined;
130
+ refType: string;
131
+ _key: string;
132
+ reason: string;
133
+ } | {
134
+ refType: string;
135
+ perspective: string;
136
+ _key: string;
137
+ reason: string;
138
+ })[];
139
+ description: string;
140
+ docCoverage: boolean;
141
+ featureArea: {
142
+ _ref: string;
143
+ _type: string;
144
+ };
145
+ id: {
146
+ _type: string;
147
+ current: string;
148
+ };
149
+ origin: {
150
+ branch: string;
151
+ commitSha: string;
152
+ contentHash: string;
153
+ lastSyncedAt: string;
154
+ path: string;
155
+ repo: string;
156
+ repoName: string;
157
+ repoOwner: string;
158
+ type: string;
159
+ author: GitAuthor;
160
+ lastEditor: GitAuthor;
161
+ };
162
+ taskPrompt: string;
163
+ };
@@ -13,7 +13,8 @@
13
13
  * @see docs/exec-plans/tasks-as-content/phase-5-content-lake-mirroring.md
14
14
  */
15
15
  import { createHash } from "crypto";
16
- import { isSlugRef, } from "../_vendor/ailf-core/index.js";
16
+ import { readFileSync } from "fs";
17
+ import { isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../_vendor/ailf-core/index.js";
17
18
  // ---------------------------------------------------------------------------
18
19
  // Public API
19
20
  // ---------------------------------------------------------------------------
@@ -56,22 +57,30 @@ export async function mirrorRepoTasks(options) {
56
57
  const areas = [...new Set(tasks.map((t) => t.featureArea))];
57
58
  const createdAreas = await ensureFeatureAreas(client, areas, dryRun);
58
59
  result.areasCreated = createdAreas;
59
- // Fetch existing mirror document content hashes for change detection
60
+ // Fetch existing mirror document state for change detection + ownership check
60
61
  const mirrorIds = tasks.map((t) => mirrorDocId(git.owner, git.name, t.id));
61
- const existingHashes = await fetchExistingHashes(client, mirrorIds);
62
+ const existingDocState = await fetchExistingDocState(client, mirrorIds);
62
63
  // Mirror each task
63
64
  for (const task of tasks) {
64
65
  try {
65
66
  const docId = mirrorDocId(git.owner, git.name, task.id);
67
+ const existing = existingDocState.get(docId);
68
+ // Skip graduated tasks — ownership was changed to "studio"
69
+ if (existing?.ownership === "studio") {
70
+ console.log(` ℹ️ Skipping "${task.id}" — graduated to Studio ownership`);
71
+ result.skipped++;
72
+ continue;
73
+ }
66
74
  const contentHash = computeTaskHash(task);
67
75
  // Skip unchanged
68
- if (existingHashes.get(docId) === contentHash) {
76
+ if (existing?.hash === contentHash) {
69
77
  result.skipped++;
70
78
  continue;
71
79
  }
72
80
  const doc = buildMirrorDocument(task, {
73
81
  contentHash,
74
82
  docId,
83
+ existingAuthor: existing?.existingAuthor,
75
84
  git,
76
85
  slugToDocId,
77
86
  });
@@ -106,12 +115,15 @@ export async function detectGitContext(repoTasksPath) {
106
115
  if (ghRepo) {
107
116
  const [owner, name] = ghRepo.split("/");
108
117
  const branch = ghHeadRef || ghRef.replace("refs/heads/", "").replace("refs/tags/", "");
118
+ // Extract author from GitHub Actions environment
119
+ const author = detectGitHubActionsAuthor();
109
120
  return {
110
121
  repo: ghRepo,
111
122
  owner: owner ?? "unknown",
112
123
  name: name ?? "unknown",
113
124
  branch: branch || "unknown",
114
125
  commitSha: ghSha || "unknown",
126
+ author,
115
127
  };
116
128
  }
117
129
  // Fallback: try git CLI
@@ -134,12 +146,15 @@ export async function detectGitContext(repoTasksPath) {
134
146
  remote.match(/([^/]+)\/([^/.]+?)(?:\.git)?$/);
135
147
  const owner = match?.[1] ?? "unknown";
136
148
  const name = match?.[2] ?? "unknown";
149
+ // Extract author from git log
150
+ const author = detectGitCliAuthor(repoTasksPath, execSync);
137
151
  return {
138
152
  repo: `${owner}/${name}`,
139
153
  owner,
140
154
  name,
141
155
  branch,
142
156
  commitSha,
157
+ author,
143
158
  };
144
159
  }
145
160
  catch {
@@ -149,10 +164,63 @@ export async function detectGitContext(repoTasksPath) {
149
164
  name: "unknown",
150
165
  branch: "unknown",
151
166
  commitSha: "unknown",
167
+ author: {},
152
168
  };
153
169
  }
154
170
  }
155
171
  // ---------------------------------------------------------------------------
172
+ // Author detection helpers
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Extract author info from GitHub Actions environment variables and
176
+ * the webhook event payload (GITHUB_EVENT_PATH).
177
+ */
178
+ function detectGitHubActionsAuthor() {
179
+ const ghActor = process.env.GITHUB_ACTOR ?? undefined;
180
+ const author = { githubUsername: ghActor };
181
+ // Try to read richer author info from the event payload
182
+ const eventPath = process.env.GITHUB_EVENT_PATH;
183
+ if (eventPath) {
184
+ try {
185
+ const event = JSON.parse(readFileSync(eventPath, "utf-8"));
186
+ // Push event: head_commit.author has name + email
187
+ if (event.head_commit?.author) {
188
+ author.gitName = event.head_commit.author.name ?? undefined;
189
+ author.gitEmail = event.head_commit.author.email ?? undefined;
190
+ }
191
+ // PR event: pull_request.user.login is the PR author
192
+ if (event.pull_request?.user?.login) {
193
+ author.githubUsername = event.pull_request.user.login;
194
+ }
195
+ }
196
+ catch {
197
+ // Event payload parsing is best-effort — fall through with GITHUB_ACTOR only
198
+ }
199
+ }
200
+ return author;
201
+ }
202
+ /**
203
+ * Extract author info from git CLI (local fallback when not in GitHub Actions).
204
+ */
205
+ function detectGitCliAuthor(cwd, execSyncFn) {
206
+ try {
207
+ const authorLine = execSyncFn('git log -1 --format="%an|%ae"', {
208
+ encoding: "utf-8",
209
+ cwd,
210
+ })
211
+ .toString()
212
+ .trim();
213
+ const [gitName, gitEmail] = authorLine.split("|");
214
+ return {
215
+ gitName: gitName || undefined,
216
+ gitEmail: gitEmail || undefined,
217
+ };
218
+ }
219
+ catch {
220
+ return {};
221
+ }
222
+ }
223
+ // ---------------------------------------------------------------------------
156
224
  // Document ID scheme
157
225
  // ---------------------------------------------------------------------------
158
226
  /**
@@ -247,42 +315,82 @@ async function ensureFeatureAreas(client, areas, dryRun) {
247
315
  await transaction.commit();
248
316
  return missing;
249
317
  }
250
- // ---------------------------------------------------------------------------
251
- // Fetch existing content hashes
252
- // ---------------------------------------------------------------------------
253
318
  /**
254
- * Fetch existing mirror documents' content hashes for change detection.
255
- * The hash is stored in origin.contentHash on the document.
319
+ * Fetch existing mirror documents' ownership and content hashes.
320
+ *
321
+ * The ownership field determines whether the mirror step is allowed to
322
+ * update the document:
323
+ * - `"repo"` or absent → safe to update (active mirror)
324
+ * - `"studio"` → skip (graduated to Studio ownership)
325
+ *
326
+ * The hash is stored in origin.contentHash for change detection.
256
327
  */
257
- async function fetchExistingHashes(client, docIds) {
328
+ async function fetchExistingDocState(client, docIds) {
258
329
  if (docIds.length === 0)
259
330
  return new Map();
260
- const query = `*[_id in $ids] { _id, "hash": origin.contentHash }`;
261
- const results = await client.fetch(query, {
262
- ids: docIds,
263
- });
331
+ const query = `*[_id in $ids] {
332
+ _id,
333
+ "hash": origin.contentHash,
334
+ ownership,
335
+ "existingAuthor": origin.author
336
+ }`;
337
+ const results = await client.fetch(query, { ids: docIds });
264
338
  const map = new Map();
265
339
  for (const r of results) {
266
- if (r.hash)
267
- map.set(r._id, r.hash);
340
+ map.set(r._id, {
341
+ hash: r.hash ?? undefined,
342
+ ownership: r.ownership ?? undefined,
343
+ existingAuthor: r.existingAuthor ?? undefined,
344
+ });
268
345
  }
269
346
  return map;
270
347
  }
271
348
  // ---------------------------------------------------------------------------
272
349
  // Build mirror document
273
350
  // ---------------------------------------------------------------------------
274
- function buildMirrorDocument(task, opts) {
275
- const { contentHash, docId, git, slugToDocId } = opts;
276
- // Build canonical docs with resolved references.
277
- // Only slug refs can be resolved to article references today.
278
- // Other ref types (path, id, perspective) are stored with reason only.
351
+ /** @internal Exported for testing — not part of the public API. */
352
+ export function buildMirrorDocument(task, opts) {
353
+ const { contentHash, docId, existingAuthor, git, slugToDocId } = opts;
354
+ // Build canonical docs with resolved references and correct refType.
355
+ // Each ref type gets the appropriate resolution fields set on the
356
+ // mirror document so Studio can display them correctly.
279
357
  const canonicalDocs = task.canonicalDocs.map((ref, i) => {
280
- const resolvedId = isSlugRef(ref) ? slugToDocId.get(ref.slug) : undefined;
281
- return {
282
- _key: `cd${i}`,
283
- ...(resolvedId ? { doc: { _ref: resolvedId, _type: "reference" } } : {}),
284
- reason: ref.reason ?? "",
285
- };
358
+ const base = { _key: `cd${i}`, reason: ref.reason ?? "" };
359
+ if (isSlugRef(ref)) {
360
+ const resolvedId = slugToDocId.get(ref.slug);
361
+ // When a slug resolves to a document, store as "id" ref with
362
+ // the resolved article reference. When unresolved, store as
363
+ // "slug" so Studio knows the resolution strategy even if the
364
+ // article doesn't exist yet.
365
+ return {
366
+ ...base,
367
+ refType: resolvedId ? "id" : "slug",
368
+ ...(resolvedId
369
+ ? { doc: { _ref: resolvedId, _type: "reference" } }
370
+ : {}),
371
+ };
372
+ }
373
+ if (isPathRef(ref)) {
374
+ return { ...base, refType: "path", path: ref.path };
375
+ }
376
+ if (isIdRef(ref)) {
377
+ return {
378
+ ...base,
379
+ refType: "id",
380
+ ...(ref.id
381
+ ? { doc: { _ref: ref.id, _type: "reference" }, docId: ref.id }
382
+ : {}),
383
+ };
384
+ }
385
+ if (isPerspectiveRef(ref)) {
386
+ return {
387
+ ...base,
388
+ refType: "perspective",
389
+ perspective: ref.perspective,
390
+ };
391
+ }
392
+ // Unknown ref type — store with reason only
393
+ return base;
286
394
  });
287
395
  // Build assertions
288
396
  const assertArray = task.assertions.map((a, i) => {
@@ -315,6 +423,8 @@ function buildMirrorDocument(task, opts) {
315
423
  return {
316
424
  _id: docId,
317
425
  _type: "ailf.task",
426
+ ownership: "repo",
427
+ status: task.status ?? "active",
318
428
  assert: assertArray,
319
429
  canonicalDocs,
320
430
  description: task.description,
@@ -325,6 +435,7 @@ function buildMirrorDocument(task, opts) {
325
435
  },
326
436
  id: { _type: "slug", current: task.id },
327
437
  origin: {
438
+ // Existing provenance fields
328
439
  branch: git.branch,
329
440
  commitSha: git.commitSha,
330
441
  contentHash,
@@ -334,6 +445,9 @@ function buildMirrorDocument(task, opts) {
334
445
  repoName: git.name,
335
446
  repoOwner: git.owner,
336
447
  type: "repo",
448
+ // Authorship: author is write-once (preserve existing), lastEditor always updates
449
+ author: existingAuthor ?? git.author,
450
+ lastEditor: git.author,
337
451
  },
338
452
  taskPrompt: task.taskPrompt,
339
453
  ...(task.baseline
@@ -295,7 +295,7 @@ function generateComment(summary, options = {}) {
295
295
  if (hasActualDeltas) {
296
296
  lines.push("| Feature | Baseline | Current | Delta | Actual Δ | Ret. Gap Δ | Infra Δ |");
297
297
  lines.push("|---------|----------|---------|-------|----------|------------|---------|");
298
- for (const a of report.areas) {
298
+ for (const a of report.areas.filter((a) => a.change !== "not-evaluated")) {
299
299
  const icon = a.change === "improved"
300
300
  ? "📈"
301
301
  : a.change === "regressed"
@@ -313,7 +313,7 @@ function generateComment(summary, options = {}) {
313
313
  else {
314
314
  lines.push("| Feature | Baseline | Current | Delta | Task | Code | Docs |");
315
315
  lines.push("|---------|----------|---------|-------|------|------|------|");
316
- for (const a of report.areas) {
316
+ for (const a of report.areas.filter((a) => a.change !== "not-evaluated")) {
317
317
  const icon = a.change === "improved"
318
318
  ? "📈"
319
319
  : a.change === "regressed"
@@ -334,6 +334,9 @@ function generateComment(summary, options = {}) {
334
334
  if (report.unchanged.length > 0) {
335
335
  parts.push(`➡️ ${report.unchanged.length} unchanged`);
336
336
  }
337
+ if (report.notEvaluated?.length > 0) {
338
+ parts.push(`⏭️ ${report.notEvaluated.length} not evaluated`);
339
+ }
337
340
  if (parts.length > 0) {
338
341
  const isEmpirical = "noiseThresholdEmpirical" in report &&
339
342
  report.noiseThresholdEmpirical === true;
@@ -36,6 +36,10 @@ export function buildReleaseImpactReport(classification, comparison, attribution
36
36
  const confirmedUnchanged = [];
37
37
  if (comparison) {
38
38
  for (const areaDelta of comparison.areas) {
39
+ // Skip areas that weren't evaluated in both runs — these are
40
+ // mismatched areas (e.g., auto-scoped release eval vs full baseline).
41
+ if (areaDelta.change === "not-evaluated")
42
+ continue;
39
43
  const regressed = areaDelta.delta < -threshold;
40
44
  // Find tasks and their attributed documents for this area
41
45
  const areaTasks = [];