@profile-as-code/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +52 -8
  2. package/dist/index.js +1286 -971
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -8,153 +8,8 @@ import { parseArgs } from "util";
8
8
  import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "fs";
9
9
  import { dirname as dirname2, join as join4 } from "path";
10
10
 
11
- // ../core/dist/registry.js
12
- function isPublicEntry(entry) {
13
- return (entry.visibility ?? "public") === "public";
14
- }
15
- function credentialLdEntries(credentials) {
16
- return credentials.filter(isPublicEntry).map((c) => ({
17
- "@type": "EducationalOccupationalCredential",
18
- ...c.title ? { name: c.title } : {},
19
- ...c.verifyUrl !== void 0 ? { url: c.verifyUrl } : {},
20
- ...c.issuer ? { awardedBy: { "@type": "Organization", name: c.issuer } } : {},
21
- ...c.issued !== void 0 ? { validFrom: c.issued } : {},
22
- ...c.expires !== void 0 ? { validThrough: c.expires } : {}
23
- }));
24
- }
25
- function integrity(path, message) {
26
- return { path, message };
27
- }
28
- function idsOf(entries) {
29
- const map = /* @__PURE__ */ new Map();
30
- for (const [index, entry] of (entries ?? []).entries()) {
31
- if (entry.id === void 0)
32
- continue;
33
- map.set(entry.id, index);
34
- }
35
- return map;
36
- }
37
- function duplicateIssues(block, entries) {
38
- const issues = [];
39
- const seen = /* @__PURE__ */ new Map();
40
- for (const [index, entry] of (entries ?? []).entries()) {
41
- if (entry.id === void 0)
42
- continue;
43
- const first = seen.get(entry.id);
44
- if (first !== void 0) {
45
- issues.push(integrity(`${block}[${index}].id`, `duplicate registry id "${entry.id}" (first used at ${block}[${first}]) \u2014 registry ids are unique per block (load fails, D12 fail class)`));
46
- } else {
47
- seen.set(entry.id, index);
48
- }
49
- }
50
- return issues;
51
- }
52
- function locatorIssues(entries) {
53
- const issues = [];
54
- for (const [index, entry] of (entries ?? []).entries()) {
55
- const hasUrl = entry.locator?.url !== void 0;
56
- const hasPath = entry.locator?.path !== void 0;
57
- if (hasUrl === hasPath) {
58
- issues.push(integrity(`artifacts[${index}].locator`, `locator must have exactly one of "url" | "path" (\xA718.3 capability matrix) \u2014 got ${hasUrl ? "both" : "neither"}`));
59
- }
60
- }
61
- return issues;
62
- }
63
- function refIssues(profile) {
64
- const credentialIds = idsOf(profile.credentials);
65
- const artifactIds = idsOf(profile.artifacts);
66
- const issues = [];
67
- for (const [pIndex, project] of (profile.projects ?? []).entries()) {
68
- for (const [eIndex, evidence] of (project.evidence ?? []).entries()) {
69
- if (evidence.credentialId !== void 0 && !credentialIds.has(evidence.credentialId)) {
70
- issues.push(integrity(`projects[${pIndex}].evidence[${eIndex}].credentialId`, `references credentials[].id "${evidence.credentialId}" which does not exist (dangling cross-ref, load fails)`));
71
- }
72
- if (evidence.artifactId !== void 0 && !artifactIds.has(evidence.artifactId)) {
73
- issues.push(integrity(`projects[${pIndex}].evidence[${eIndex}].artifactId`, `references artifacts[].id "${evidence.artifactId}" which does not exist (dangling cross-ref, load fails)`));
74
- }
75
- }
76
- }
77
- for (const [pIndex, pub] of (profile.publications ?? []).entries()) {
78
- for (const [rIndex, ref] of (pub.artifactRefs ?? []).entries()) {
79
- if (!artifactIds.has(ref)) {
80
- issues.push(integrity(`publications[${pIndex}].artifactRefs[${rIndex}]`, `references artifacts[].id "${ref}" which does not exist (dangling cross-ref, load fails)`));
81
- }
82
- }
83
- }
84
- for (const [pIndex, press] of (profile.press ?? []).entries()) {
85
- for (const [rIndex, ref] of (press.artifactRefs ?? []).entries()) {
86
- if (!artifactIds.has(ref)) {
87
- issues.push(integrity(`press[${pIndex}].artifactRefs[${rIndex}]`, `references artifacts[].id "${ref}" which does not exist (dangling cross-ref, load fails)`));
88
- }
89
- }
90
- }
91
- return issues;
92
- }
93
- function checkRegistryIntegrity(profile) {
94
- return [
95
- ...duplicateIssues("credentials", profile.credentials),
96
- ...duplicateIssues("artifacts", profile.artifacts),
97
- ...locatorIssues(profile.artifacts),
98
- ...refIssues(profile)
99
- ];
100
- }
101
- function todayIso() {
102
- const now = /* @__PURE__ */ new Date();
103
- const mm = String(now.getMonth() + 1).padStart(2, "0");
104
- const dd = String(now.getDate()).padStart(2, "0");
105
- return `${now.getFullYear()}-${mm}-${dd}`;
106
- }
107
- function registryCheck(profile) {
108
- const findings = [];
109
- const credentials = profile.credentials ?? [];
110
- const artifacts = profile.artifacts ?? [];
111
- const today = todayIso();
112
- for (const [index, credential] of credentials.entries()) {
113
- if (credential.expires !== void 0 && credential.expires < today) {
114
- findings.push({
115
- code: "check/credential-expired",
116
- severity: "warn",
117
- message: `credential "${credential.title}" (${credential.id}) expired ${credential.expires} \u2014 renew or update the workspace`,
118
- path: `credentials[${index}]`
119
- });
120
- }
121
- }
122
- const nonPublic = (id, kind, list) => {
123
- if (id === void 0)
124
- return void 0;
125
- const entry = list.find((e) => e.id === id);
126
- if (entry === void 0 || entry.visibility === void 0 || isPublicEntry(entry))
127
- return void 0;
128
- return { blockPath: `${kind}[${list.indexOf(entry)}]`, visibility: entry.visibility, id };
129
- };
130
- const flagLeak = (leak, refPath, where) => {
131
- if (leak === void 0)
132
- return;
133
- findings.push({
134
- code: "check/private-in-public-output",
135
- severity: "error",
136
- message: `${where} references registry entry "${leak.id}" with visibility "${leak.visibility}" \u2014 public output renders only public entries (\xA718.5); make it public or drop the reference`,
137
- path: refPath
138
- });
139
- };
140
- for (const [pIndex, project] of (profile.projects ?? []).entries()) {
141
- for (const [eIndex, evidence] of (project.evidence ?? []).entries()) {
142
- flagLeak(nonPublic(evidence.credentialId, "credentials", credentials), `projects[${pIndex}].evidence[${eIndex}].credentialId`, "evidence claim attests a credential");
143
- flagLeak(nonPublic(evidence.artifactId, "artifacts", artifacts), `projects[${pIndex}].evidence[${eIndex}].artifactId`, "evidence claim proves an artifact");
144
- }
145
- }
146
- for (const [pIndex, pub] of (profile.publications ?? []).entries()) {
147
- for (const [rIndex, ref] of (pub.artifactRefs ?? []).entries()) {
148
- flagLeak(nonPublic(ref, "artifacts", artifacts), `publications[${pIndex}].artifactRefs[${rIndex}]`, "publication");
149
- }
150
- }
151
- for (const [pIndex, press] of (profile.press ?? []).entries()) {
152
- for (const [rIndex, ref] of (press.artifactRefs ?? []).entries()) {
153
- flagLeak(nonPublic(ref, "artifacts", artifacts), `press[${pIndex}].artifactRefs[${rIndex}]`, "press mention");
154
- }
155
- }
156
- return findings;
157
- }
11
+ // ../core/dist/ledger.js
12
+ import { createHash } from "crypto";
158
13
 
159
14
  // ../core/dist/generated/platforms.gen.js
160
15
  var PLATFORM_SLUGS = [
@@ -328,925 +183,1295 @@ function textOf(v) {
328
183
  }
329
184
  var PLATFORMS = PLATFORM_SLUGS;
330
185
 
331
- // ../core/dist/structured-data.js
332
- var DEFAULT_LINK_ROLE = "realization";
333
- function linkRole(link) {
334
- return link.role ?? DEFAULT_LINK_ROLE;
186
+ // ../core/dist/ledger.js
187
+ var LEDGER_META = /* @__PURE__ */ new Set(["id", "origin"]);
188
+ function canonicalJson(value) {
189
+ const sort = (v) => {
190
+ if (Array.isArray(v))
191
+ return v.map(sort);
192
+ if (v !== null && typeof v === "object") {
193
+ const out = {};
194
+ for (const key of Object.keys(v).sort()) {
195
+ out[key] = sort(v[key]);
196
+ }
197
+ return out;
198
+ }
199
+ return v;
200
+ };
201
+ return JSON.stringify(sort(value));
335
202
  }
336
- function linksWithRole(identity, role) {
337
- return (identity?.links ?? []).filter((l) => linkRole(l) === role).map((l) => l.url);
203
+ function hashUnit(unit) {
204
+ const stripped = {};
205
+ if (unit !== null && typeof unit === "object" && !Array.isArray(unit)) {
206
+ for (const [key, value] of Object.entries(unit)) {
207
+ if (!LEDGER_META.has(key))
208
+ stripped[key] = value;
209
+ }
210
+ }
211
+ return createHash("sha256").update(canonicalJson(stripped)).digest("hex");
338
212
  }
339
- function entityId(identity) {
340
- return (identity?.links ?? []).find((l) => linkRole(l) === "identity")?.url;
213
+ var LEDGER_KEY_PATTERN = /^(identity|positioning|keywords)$|^(bios|headlines|skills|socialHandles|experience|projects|publications|education|press):.+$/;
214
+ var ROW_BLOCKS = ["experience", "projects", "publications", "education", "press"];
215
+ var ROW_CONFIG = {
216
+ experience: {
217
+ block: "experience",
218
+ label: (r) => `${textOf(r.role)} @ ${String(r.org ?? "")}`
219
+ },
220
+ projects: { block: "projects", label: (r) => String(r.name ?? "") },
221
+ publications: { block: "publications", label: (r) => String(r.title ?? "") },
222
+ education: {
223
+ block: "education",
224
+ label: (r) => `${textOf(r.degree)} @ ${String(r.institution ?? "")}`
225
+ },
226
+ press: { block: "press", label: (r) => textOf(r.title) }
227
+ };
228
+ function trackableUnits(profile) {
229
+ const units = [];
230
+ const push = (key, kind, block, label, unit, origin = "user") => {
231
+ units.push({ key, kind, block, label, origin, hash: hashUnit(unit) });
232
+ };
233
+ for (const block of ["identity", "positioning", "keywords"]) {
234
+ const value = profile[block];
235
+ if (value !== void 0)
236
+ push(block, "root", block, block, value);
237
+ }
238
+ for (const bio of profile.bios) {
239
+ push(`bios:${bio.platform}:${bio.lang}:${bio.length}`, "bio", "bios", `${bio.platform} ${bio.lang}/${bio.length}`, bio);
240
+ }
241
+ for (const h of profile.headlines) {
242
+ push(`headlines:${h.platform}:${h.lang}`, "headline", "headlines", `${h.platform} ${h.lang}`, h);
243
+ }
244
+ for (const s of profile.skills) {
245
+ push(`skills:${s.name}`, "skill", "skills", s.name, s);
246
+ }
247
+ for (const h of profile.socialHandles) {
248
+ push(`socialHandles:${h.platform}`, "handle", "socialHandles", `${h.platform} @${h.handle}`, h);
249
+ }
250
+ for (const { block } of Object.values(ROW_CONFIG)) {
251
+ const rows = profile[block];
252
+ if (!Array.isArray(rows))
253
+ continue;
254
+ for (const raw of rows) {
255
+ const row = raw;
256
+ if (typeof row.id !== "string" || row.id === "")
257
+ continue;
258
+ const origin = row.origin === "template" ? "template" : "user";
259
+ push(`${block}:${row.id}`, "row", block, ROW_CONFIG[block].label(row), row, origin);
260
+ }
261
+ }
262
+ return units;
341
263
  }
342
- function sameAsUrls(profile) {
343
- const identity = profile.identity;
344
- const claimed = (profile.socialHandles ?? []).filter((h) => h.status === "claimed" && h.url !== void 0).map((h) => h.url);
345
- const id = entityId(identity);
346
- return [...linksWithRole(identity, "realization"), ...claimed].filter((url) => url !== id).filter((url, index, all) => all.indexOf(url) === index);
264
+ function untrackedRows(profile) {
265
+ const out = [];
266
+ for (const { block } of Object.values(ROW_CONFIG)) {
267
+ const rows = profile[block];
268
+ if (!Array.isArray(rows))
269
+ continue;
270
+ for (const raw of rows) {
271
+ const row = raw;
272
+ if (typeof row.id !== "string" || row.id === "") {
273
+ out.push({ block, label: ROW_CONFIG[block].label(row) });
274
+ }
275
+ }
276
+ }
277
+ return out;
347
278
  }
348
- function currentRole(profile) {
349
- const items = [...profile.experience ?? []];
350
- const byStart = (a, b) => b.start.localeCompare(a.start);
351
- const current = items.filter((item) => item.end === "present").sort(byStart)[0];
352
- const latest = items.sort(byStart)[0];
353
- const pick = current ?? latest;
354
- return pick ? { jobTitle: textOf(pick.role), worksFor: pick.org } : {};
355
- }
356
- function hasCredentialLd(profile) {
357
- const entries = credentialLdEntries(profile.credentials ?? []);
358
- return entries.length > 0 ? entries : void 0;
359
- }
360
- function structuredData(profile, lang = "en") {
361
- const identity = profile.identity;
362
- const isBrand = profile.flavor === "brand";
363
- const credentialsLd = hasCredentialLd(profile);
364
- const sameAs = sameAsUrls(profile);
365
- const entityIdValue = entityId(identity);
366
- if (isBrand) {
367
- const slogan = identity.tagline?.[lang] || identity.tagline?.en || void 0;
368
- const description = identity.mission?.[lang] || identity.mission?.en || void 0;
369
- const fields2 = {
370
- name: Boolean(identity.name),
371
- sameAs,
372
- ...entityIdValue !== void 0 ? { id: entityIdValue } : {},
373
- ...identity.email !== void 0 ? { email: identity.email } : {},
374
- ...identity.logo ? { logo: identity.logo } : {},
375
- ...identity.established ? { foundingDate: identity.established } : {},
376
- ...slogan ? { slogan } : {},
377
- ...description ? { description } : {},
378
- ...identity.location ? { address: identity.location } : {}
379
- };
380
- const considered2 = [
381
- "name",
382
- "sameAs",
383
- "email",
384
- "logo",
385
- "foundingDate",
386
- "slogan",
387
- "description",
388
- "address"
389
- ];
390
- const populated2 = 1 + (sameAs.length > 0 ? 1 : 0) + (identity.email !== void 0 ? 1 : 0) + (identity.logo ? 1 : 0) + (identity.established ? 1 : 0) + (slogan ? 1 : 0) + (description ? 1 : 0) + (identity.location ? 1 : 0);
391
- const ready2 = Boolean(identity.name);
392
- const jsonLd2 = identity.name ? {
393
- "@context": "https://schema.org",
394
- "@type": "Organization",
395
- ...entityIdValue !== void 0 ? { "@id": entityIdValue } : {},
396
- name: identity.name,
397
- ...identity.email !== void 0 ? { email: identity.email } : {},
398
- ...identity.logo ? { logo: identity.logo } : {},
399
- ...identity.established ? { foundingDate: identity.established } : {},
400
- ...slogan ? { slogan } : {},
401
- ...description ? { description } : {},
402
- sameAs,
403
- ...credentialsLd ? { hasCredential: credentialsLd } : {}
404
- } : void 0;
279
+ function deployStatus(profile) {
280
+ const units = trackableUnits(profile);
281
+ const ledger = profile.deployments ?? {};
282
+ const byKey = new Set(units.map((u) => u.key));
283
+ const removed = [];
284
+ for (const [key, surfaces] of Object.entries(ledger)) {
285
+ if (!byKey.has(key)) {
286
+ removed.push({ key, surfaces: Object.keys(surfaces).sort() });
287
+ }
288
+ }
289
+ const statuses = units.map((unit) => {
290
+ const records = ledger[unit.key] ?? {};
291
+ const surfaces = {};
292
+ let stale = false;
293
+ let deployed = 0;
294
+ for (const [surface, record] of Object.entries(records)) {
295
+ deployed += 1;
296
+ const inSync = record.contentHash === unit.hash;
297
+ surfaces[surface] = inSync ? "in-sync" : "stale";
298
+ if (!inSync)
299
+ stale = true;
300
+ }
405
301
  return {
406
- entityType: "Organization",
407
- ready: ready2,
408
- completeness: populated2 / considered2.length,
409
- fields: fields2,
410
- ...jsonLd2 ? { jsonLd: jsonLd2 } : {}
302
+ ...unit,
303
+ surfaces,
304
+ state: stale ? "stale" : deployed > 0 ? "in-sync" : "never"
411
305
  };
306
+ });
307
+ return { units: statuses, untracked: untrackedRows(profile), removed };
308
+ }
309
+ function checkLedgerIntegrity(profile) {
310
+ const issues = [];
311
+ const root = profile;
312
+ for (const block of ROW_BLOCKS) {
313
+ const rows = root[block];
314
+ if (!Array.isArray(rows))
315
+ continue;
316
+ const seen = /* @__PURE__ */ new Map();
317
+ rows.forEach((raw, index) => {
318
+ const id = raw.id;
319
+ if (typeof id !== "string" || id === "")
320
+ return;
321
+ const count = seen.get(id) ?? 0;
322
+ if (count > 0) {
323
+ issues.push({
324
+ path: `${block}[${index}].id`,
325
+ message: `duplicate id "${id}" in ${block} \u2014 entry ids must be unique per block (D18 \xA719.2)`
326
+ });
327
+ }
328
+ seen.set(id, count + 1);
329
+ });
412
330
  }
413
- const role = currentRole(profile);
414
- const fields = {
415
- name: Boolean(identity.name),
416
- sameAs,
417
- ...entityIdValue !== void 0 ? { id: entityIdValue } : {},
418
- ...identity.email !== void 0 ? { email: identity.email } : {},
419
- ...identity.photo ? { image: identity.photo } : {},
420
- ...identity.location ? { address: identity.location } : {},
421
- ...role.jobTitle ? { jobTitle: role.jobTitle } : {},
422
- ...role.worksFor ? { worksFor: role.worksFor } : {}
423
- };
424
- const considered = ["name", "sameAs", "email", "image", "address", "jobTitle", "worksFor"];
425
- const populated = 1 + (sameAs.length > 0 ? 1 : 0) + (identity.email !== void 0 ? 1 : 0) + (identity.photo ? 1 : 0) + (identity.location ? 1 : 0) + (role.jobTitle ? 1 : 0) + (role.worksFor ? 1 : 0);
426
- const ready = Boolean(identity.name);
427
- const jsonLd = identity.name ? {
428
- "@context": "https://schema.org",
429
- "@type": "Person",
430
- ...entityIdValue !== void 0 ? { "@id": entityIdValue } : {},
431
- name: identity.name,
432
- ...identity.email !== void 0 ? { email: identity.email } : {},
433
- ...identity.photo ? { image: identity.photo } : {},
434
- ...identity.location ? { address: identity.location } : {},
435
- ...role.jobTitle ? { jobTitle: role.jobTitle } : {},
436
- ...role.worksFor ? { worksFor: role.worksFor } : {},
437
- sameAs,
438
- ...credentialsLd ? { hasCredential: credentialsLd } : {}
439
- } : void 0;
440
- return {
441
- entityType: "Person",
442
- ready,
443
- completeness: populated / considered.length,
444
- fields,
445
- ...jsonLd ? { jsonLd } : {}
331
+ const ledger = root.deployments;
332
+ if (ledger === void 0)
333
+ return issues;
334
+ for (const key of Object.keys(ledger)) {
335
+ if (!LEDGER_KEY_PATTERN.test(key)) {
336
+ issues.push({
337
+ path: `deployments.${key}`,
338
+ message: `"${key}" is not a valid ledger key (D18 \xA719.2 key space: root block names or block:key composite)`
339
+ });
340
+ }
341
+ }
342
+ const platforms = new Set(PLATFORMS);
343
+ for (const [key, surfaces] of Object.entries(ledger)) {
344
+ for (const surface of Object.keys(surfaces)) {
345
+ if (!platforms.has(surface)) {
346
+ issues.push({
347
+ path: `deployments.${key}.${surface}`,
348
+ message: `surface "${surface}" is not in the platform set (D16)`
349
+ });
350
+ }
351
+ }
352
+ }
353
+ const compositeCount = (prefix, key, rows) => {
354
+ if (!Array.isArray(rows))
355
+ return;
356
+ const counts = /* @__PURE__ */ new Map();
357
+ for (const raw of rows) {
358
+ const k = key(raw);
359
+ counts.set(k, (counts.get(k) ?? 0) + 1);
360
+ }
361
+ for (const [key2, count] of counts) {
362
+ if (count > 1 && ledger[`${prefix}:${key2}`] !== void 0) {
363
+ issues.push({
364
+ path: `deployments.${prefix}:${key2}`,
365
+ message: `ambiguous ledger key "${prefix}:${key2}" \u2014 ${count} rows share this natural key; disambiguate before tracking (D18 \xA719.2)`
366
+ });
367
+ }
368
+ }
446
369
  };
370
+ compositeCount("bios", (r) => `${r.platform}:${r.lang}:${r.length}`, root.bios);
371
+ compositeCount("headlines", (r) => `${r.platform}:${r.lang}`, root.headlines);
372
+ compositeCount("skills", (r) => String(r.name), root.skills);
373
+ compositeCount("socialHandles", (r) => String(r.platform), root.socialHandles);
374
+ return issues;
447
375
  }
448
376
 
449
- // ../core/dist/compile.js
450
- var LANG_IDS = ["en", "es", "fr"];
451
- function compile(profile, platform, lang = "en") {
452
- if (!PLATFORMS.includes(platform)) {
453
- throw new TypeError(`compile: unknown platform "${String(platform)}" (allowed: ${PLATFORMS.join(", ")})`);
454
- }
455
- if (!LANG_IDS.includes(lang)) {
456
- throw new TypeError(`compile: unknown language "${String(lang)}" (allowed: ${LANG_IDS.join(", ")})`);
457
- }
458
- const fallbacks = [];
459
- const langText = (langMap, path) => {
460
- const direct = langMap?.[lang] || void 0;
461
- if (direct !== void 0)
462
- return direct;
463
- const en = langMap?.en || void 0;
464
- if (en !== void 0) {
465
- fallbacks.push(path);
466
- return en;
377
+ // ../core/dist/completeness.js
378
+ var COMPLETENESS_CATALOG = [
379
+ { id: "presence", label: "Required blocks present", block: "profile", weight: 0.3 },
380
+ { id: "langCoverage", label: "Language coverage (en/es/fr)", block: "langMaps", weight: 0.15 },
381
+ { id: "geoAnchors", label: "GEO semantic anchors", block: "positioning.geo", weight: 0.15 },
382
+ { id: "evidence", label: "Claims carry evidence", block: "projects.evidence", weight: 0.15 },
383
+ { id: "deployment", label: "Deployed to a surface", block: "deployments", weight: 0.25 }
384
+ ];
385
+ var DEFAULT_COMPLETENESS_POLICY = {
386
+ target: "profile-complete",
387
+ langs: ["en", "es", "fr"]
388
+ };
389
+ function isLangMap(v) {
390
+ if (v === null || typeof v !== "object" || Array.isArray(v))
391
+ return false;
392
+ return Object.values(v).every((x) => typeof x === "string");
393
+ }
394
+ function isLangMapList(v) {
395
+ if (v === null || typeof v !== "object" || Array.isArray(v))
396
+ return false;
397
+ return Object.values(v).every((x) => Array.isArray(x));
398
+ }
399
+ function langMaps(profile) {
400
+ const out = [];
401
+ const has = (v) => typeof v === "string" && v.trim() !== "";
402
+ const push = (path, value) => {
403
+ if (isLangMap(value)) {
404
+ out.push({ path, langs: new Set(Object.keys(value).filter((l) => has(value[l]))) });
405
+ } else if (isLangMapList(value)) {
406
+ const langs = /* @__PURE__ */ new Set();
407
+ for (const [lang, lines2] of Object.entries(value)) {
408
+ if (lines2.some((line2) => line2.trim() !== ""))
409
+ langs.add(lang);
410
+ }
411
+ out.push({ path, langs });
467
412
  }
468
- return void 0;
469
413
  };
470
- const positioning = profile.positioning;
471
- const audiences = {};
472
- for (const [name, map] of Object.entries(positioning?.audiences ?? {})) {
473
- const text = langText(map, `positioning.audiences.${name}`);
474
- if (text !== void 0)
475
- audiences[name] = text;
414
+ const pushOptional = (path, value) => {
415
+ if (value !== void 0 && typeof value !== "string")
416
+ push(path, value);
417
+ };
418
+ push("identity.mission", profile.identity?.mission);
419
+ push("identity.tagline", profile.identity?.tagline);
420
+ push("positioning.statement", profile.positioning?.statement);
421
+ for (const [name, map] of Object.entries(profile.positioning?.audiences ?? {})) {
422
+ push(`positioning.audiences.${name}`, map);
476
423
  }
477
- const brandAudiences = {};
424
+ push("positioning.geo.citationsGoal", profile.positioning?.geo?.citationsGoal);
478
425
  for (const [name, map] of Object.entries(profile.audiences ?? {})) {
479
- const text = langText(map, `audiences.${name}`);
480
- if (text !== void 0)
481
- brandAudiences[name] = text;
426
+ push(`audiences.${name}`, map);
482
427
  }
483
- const platformChecks = (profile.checks ?? []).filter((c) => c.platform === "all" || c.platform === platform);
428
+ for (const [i, e] of (profile.experience ?? []).entries()) {
429
+ pushOptional(`experience[${i}].role`, e.role);
430
+ push(`experience[${i}].highlights`, e.highlights);
431
+ }
432
+ for (const [i, e] of (profile.education ?? []).entries()) {
433
+ pushOptional(`education[${i}].degree`, e.degree);
434
+ pushOptional(`education[${i}].field`, e.field);
435
+ push(`education[${i}].note`, e.note);
436
+ }
437
+ for (const [i, p] of (profile.projects ?? []).entries()) {
438
+ push(`projects[${i}].description`, p.description);
439
+ for (const [j, ev] of (p.evidence ?? []).entries()) {
440
+ push(`projects[${i}].evidence[${j}].claim`, ev.claim);
441
+ }
442
+ }
443
+ for (const [i, p] of (profile.press ?? []).entries()) {
444
+ pushOptional(`press[${i}].title`, p.title);
445
+ pushOptional(`press[${i}].outlet`, p.outlet);
446
+ }
447
+ for (const [i, a] of (profile.artifacts ?? []).entries()) {
448
+ push(`artifacts[${i}].description`, a.description);
449
+ }
450
+ push("newsletter.name", profile.newsletter?.name);
451
+ push("newsletter.description", profile.newsletter?.description);
452
+ push("podcast.title", profile.podcast?.title);
453
+ push("podcast.description", profile.podcast?.description);
454
+ push("brandVoice.tone", profile.identity?.brandVoice?.tone);
455
+ return out;
456
+ }
457
+ function anyOf(v) {
458
+ return (v?.length ?? 0) > 0;
459
+ }
460
+ function hasPositioning(profile) {
461
+ return profile.positioning?.statement !== void 0 || anyOf(Object.keys(profile.positioning?.audiences ?? {}));
462
+ }
463
+ function hasKeywords(profile) {
464
+ const kw = profile.keywords;
465
+ if (kw === void 0)
466
+ return false;
467
+ return anyOf(Object.keys(kw.seo ?? {})) || anyOf(kw.geo?.associations) || anyOf(kw.geo?.claims);
468
+ }
469
+ function presenceChecks(profile) {
470
+ const brand = profile.flavor === "brand";
471
+ return [
472
+ { label: "identity", present: (profile.identity?.name ?? "").length > 0 },
473
+ { label: "positioning", present: hasPositioning(profile) },
474
+ { label: "bios", present: anyOf(profile.bios) },
475
+ { label: "headlines", present: anyOf(profile.headlines) },
476
+ { label: "experience", present: anyOf(profile.experience) },
477
+ { label: "projects", present: anyOf(profile.projects) },
478
+ { label: "skills", present: anyOf(profile.skills) },
479
+ { label: "publications", present: anyOf(profile.publications) },
480
+ { label: "keywords", present: hasKeywords(profile) },
481
+ // Flavor-specific surface: brand expects its handle matrix; person expects education.
482
+ brand ? { label: "socialHandles", present: anyOf(profile.socialHandles) } : { label: "education", present: anyOf(profile.education) }
483
+ ];
484
+ }
485
+ function presenceScore(profile) {
486
+ const checks = presenceChecks(profile);
487
+ const present = checks.filter((c) => c.present);
488
+ const missing = checks.filter((c) => !c.present).map((c) => c.label);
484
489
  return {
485
- platform,
486
- lang,
487
- flavor: profile.flavor ?? "person",
488
- // facts — untouched
489
- identity: profile.identity,
490
- experience: profile.experience ?? [],
491
- projects: profile.projects ?? [],
492
- publications: profile.publications ?? [],
493
- skills: profile.skills ?? [],
494
- socialHandles: profile.socialHandles ?? [],
495
- newsletter: profile.newsletter,
496
- podcast: profile.podcast,
497
- press: profile.press ?? [],
498
- brandAudiences,
499
- // language-aware text
500
- bios: (profile.bios ?? []).filter((b) => b.platform === platform && b.lang === lang),
501
- headlines: (profile.headlines ?? []).filter((h) => h.platform === platform && h.lang === lang),
502
- statement: langText(positioning?.statement, "positioning.statement"),
503
- audiences,
504
- citationsGoal: langText(positioning?.geo?.citationsGoal, "positioning.geo.citationsGoal"),
505
- keywords: {
506
- seo: profile.keywords?.seo?.[platform] ?? [],
507
- geo: profile.keywords?.geo ?? {}
508
- },
509
- checks: platformChecks,
510
- // Evidence registry (v1.4/D17, §18) — facts, pass through untouched.
511
- credentials: profile.credentials ?? [],
512
- artifacts: profile.artifacts ?? [],
513
- ...profile.active?.[platform] ? { active: profile.active[platform] } : {},
514
- // v1.6 (#103): entity structured-data identity — assembled ONCE here, so no
515
- // renderer re-derives `@id`/`sameAs` (SPEC §15.4, the §18.9 precedent).
516
- structuredData: structuredData(profile, lang),
517
- fallbacks
490
+ score: present.length / checks.length,
491
+ detail: missing.length === 0 ? `${present.length}/${checks.length} blocks present` : `${present.length}/${checks.length} blocks present \u2014 missing: ${missing.join(", ")}`
518
492
  };
519
493
  }
520
-
521
- // ../core/dist/drift.js
522
- function hostMatchesPlatform(platform, host) {
523
- const hosts = HANDLE_HOSTS[platform];
524
- if (hosts === void 0)
525
- return true;
526
- if (hosts.includes(host))
527
- return true;
528
- const subdomainHosts = PLATFORM_SUBDOMAIN_HOSTS[platform];
529
- if (subdomainHosts?.some((h) => host === h || host.endsWith(`.${h}`)))
530
- return true;
531
- return false;
494
+ function langCoverageScore(profile, langs) {
495
+ const maps = langMaps(profile);
496
+ if (maps.length === 0)
497
+ return { score: 0, detail: "no language maps to measure" };
498
+ const denom = Math.max(1, langs.length);
499
+ const avg = maps.reduce((sum, m) => sum + langs.filter((l) => m.langs.has(l)).length / denom, 0) / maps.length;
500
+ const full = maps.filter((m) => langs.every((l) => m.langs.has(l))).length;
501
+ return {
502
+ score: avg,
503
+ detail: `${full}/${maps.length} texts cover ${langs.join("/")}`
504
+ };
532
505
  }
533
- function hostOf(url) {
534
- try {
535
- return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
536
- } catch {
537
- return void 0;
506
+ function geoAnchorsScore(profile) {
507
+ const anchors = profile.positioning?.geo?.semanticAssociations ?? [];
508
+ if (anchors.length === 0)
509
+ return { score: 0, detail: "no semantic associations declared" };
510
+ const normal = (s) => s.trim().toLowerCase();
511
+ const reinforced = /* @__PURE__ */ new Set();
512
+ for (const kw of profile.keywords?.geo?.associations ?? [])
513
+ reinforced.add(normal(kw));
514
+ for (const s of profile.skills ?? []) {
515
+ for (const kw of s.geoKeywords ?? [])
516
+ reinforced.add(normal(kw));
517
+ for (const kw of s.keywords ?? [])
518
+ reinforced.add(normal(kw));
519
+ }
520
+ for (const p of profile.projects ?? []) {
521
+ for (const kw of p.keywords ?? [])
522
+ reinforced.add(normal(kw));
523
+ }
524
+ const hit = anchors.filter((a) => reinforced.has(normal(a))).length;
525
+ return {
526
+ score: (hit / anchors.length) ** 2,
527
+ detail: `${hit}/${anchors.length} anchors reinforced elsewhere`
528
+ };
529
+ }
530
+ function evidenceScore(profile) {
531
+ const projects = profile.projects ?? [];
532
+ const withEvidence = projects.filter((p) => (p.evidence ?? []).length > 0);
533
+ const sourced = withEvidence.filter((p) => (p.evidence ?? []).some((c) => (c.source ?? "").trim() !== "")).length;
534
+ const claimsOK = projects.length === 0 ? 0 : sourced / projects.length;
535
+ const hasRegistry = (profile.credentials ?? []).length + (profile.artifacts ?? []).length > 0;
536
+ const score = 0.7 * claimsOK + 0.3 * (hasRegistry ? 1 : 0);
537
+ return {
538
+ score: Math.min(1, score),
539
+ detail: `${sourced}/${projects.length} projects carry sourced evidence \xB7 registry ${hasRegistry ? "present" : "empty"}`
540
+ };
541
+ }
542
+ function deploymentScore(profile) {
543
+ const units = trackableUnits(profile);
544
+ if (units.length === 0)
545
+ return { score: 0, detail: "no trackable units yet" };
546
+ const deployed = units.filter((u) => Object.keys(profile.deployments[u.key] ?? {}).length > 0).length;
547
+ return { score: deployed / units.length, detail: `${deployed}/${units.length} units deployed` };
548
+ }
549
+ function completeness(profile, policy = DEFAULT_COMPLETENESS_POLICY) {
550
+ const target = policy.target ?? DEFAULT_COMPLETENESS_POLICY.target ?? "";
551
+ const langs = policy.langs ?? DEFAULT_COMPLETENESS_POLICY.langs ?? ["en"];
552
+ const overrides = new Map((policy.items ?? []).map((i) => [i.id, i.weight]));
553
+ const scored = {
554
+ presence: presenceScore,
555
+ langCoverage: (p) => langCoverageScore(p, langs),
556
+ geoAnchors: geoAnchorsScore,
557
+ evidence: evidenceScore,
558
+ deployment: deploymentScore
559
+ };
560
+ const items = [];
561
+ for (const entry of COMPLETENESS_CATALOG) {
562
+ const weight = overrides.get(entry.id) ?? entry.weight;
563
+ if (weight <= 0)
564
+ continue;
565
+ const { score, detail } = scored[entry.id](profile);
566
+ const clamped = Math.max(0, Math.min(1, score));
567
+ items.push({
568
+ id: entry.id,
569
+ label: entry.label,
570
+ block: entry.block,
571
+ score: clamped,
572
+ weight,
573
+ satisfied: clamped === 1,
574
+ detail
575
+ });
538
576
  }
577
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
578
+ const earned = items.reduce((sum, i) => sum + i.score * i.weight, 0);
579
+ const raw = totalWeight === 0 ? 0 : earned / totalWeight * 100;
580
+ return {
581
+ // One decimal: the number is a VIEW of the weighted items, and rounding it
582
+ // here makes the rendered surface byte-stable for the same profile.
583
+ percent: Math.round(raw * 10) / 10,
584
+ satisfied: items.filter((i) => i.satisfied).length,
585
+ total: items.length,
586
+ target,
587
+ items
588
+ };
539
589
  }
540
- function normalizeUrl(url) {
541
- return url.trim().replace(/\/+$/, "");
590
+ function completenessLine(report2) {
591
+ return `${report2.percent}% \u2014 ${report2.satisfied}/${report2.total}`;
542
592
  }
543
- function isPlatformSurface(url) {
544
- const host = hostOf(url);
545
- if (!host)
546
- return false;
547
- if (PLATFORM_HOSTS.includes(host))
548
- return true;
549
- return Object.values(PLATFORM_SUBDOMAIN_HOSTS).flat().some((h) => host.endsWith(`.${h}`));
593
+
594
+ // ../core/dist/load.js
595
+ import { existsSync, readdirSync, readFileSync } from "fs";
596
+ import { join } from "path";
597
+ import { parseDocument } from "yaml";
598
+
599
+ // ../core/dist/generated/schema.zod.js
600
+ import { z } from "zod";
601
+ var profileSchema = z.object({ "identity": z.object({ "name": z.string().min(1), "email": z.string().regex(new RegExp(`^[^@\\s"']+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$`)).describe("v1.5 (additive, issue #71): contact email of the person/brand (schema.org `email` on Person/Organization). A fact like name/location \u2014 NOT a link: renderers emit it as a mailto: href in the Links section and as the JSON-LD `email` property; `sameAs` stays page-URLs-only. Lives OUTSIDE identity.links[] by design \u2014 the ^https?:// rule of $defs/url (issue #53) stays strict on every URL field, so a mailto: row in links[] fails validation (D12). RFC-5322-adjacent (issue #71 review): local part lax (no whitespace/quotes), domain hostname-shaped (labels [A-Za-z0-9-], no empty/trailing/consecutive dots \u2014 unicode domains must use punycode).").optional(), "handle": z.string().describe("v1.1: primary handle (e.g. X @cluciani_ve).").optional(), "timezone": z.string().describe("v1.1: IANA timezone (e.g. America/Guayaquil).").optional(), "orgName": z.string().min(1).describe("v1.1 (additive, brand): legal/registered org name when it differs from the display name (name = display, e.g. 'Topia Press').").optional(), "logo": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "brandVoice": z.object({ "tone": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "values": z.array(z.string()).optional(), "avoid": z.array(z.string()).optional() }).strict().describe("v1.1 (additive, brand): tone matrix seed \u2014 overall tone (langMap), core values, words/tones to avoid. Per-platform voice refinement lives in the platform skill (\xA717).").optional(), "mission": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "tagline": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "established": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "location": z.string().optional(), "photo": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "links": z.array(z.object({ "platform": z.string().optional(), "label": z.string().optional(), "role": z.enum(["identity", "realization", "aggregator"]).describe("v1.6 (#103): the ROLE this link plays with respect to the entity \u2014 a translation layer of this engine onto schema.org, never standard vocabulary. `identity` \u2192 schema.org:url (the page IS the entity; strong authority; the one eligible for JSON-LD @id). `realization` (DEFAULT when absent) \u2192 schema.org:sameAs (the page indirectly indicates the entity; weak authority; these \u2014 and only these \u2014 form the sameAs set). `aggregator` \u2192 schema.org:mainEntityOfPage (link-in-bio / redirector that aggregates references to the entity; medium authority; never eligible for @id). Absence keeps every pre-v1.6 workspace VALIDATING unchanged (additive, dev-methodology \xA73) \u2014 it does NOT keep the output byte-identical: v1.6 also makes github-readme emit jobTitle/worksFor, narrows sameAs to realization links (+ claimed handles), and adds the geo/entity-id gap report.").optional(), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.") }).strict()).describe("Canonical link set. Renderers and skills read URLs ONLY from here \u2014 no per-platform URL duplication (drift source). v1.6 (#103): items carry an optional `role` distinguishing the entity's own page (schema.org:url) from pages that merely indicate it (schema.org:sameAs) or aggregate references to it (schema.org:mainEntityOfPage).").optional() }).strict(), "flavor": z.enum(["person", "brand"]).describe("v1.1 (additive): workspace flavor (D15). person (default, absent = person) or brand. Brand enables the org identity variant, the socialHandles/newsletter/podcast/press/audiences blocks and Organization GEO (\xA717).").optional(), "socialHandles": z.array(z.object({ "platform": z.enum(["github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Platform set (ONE source, dedup). v1: person surfaces. v1.1 (additive, brand \xA717): brand surfaces + the socialHandles matrix key space. v1.2 (additive, D16): Postiz parity + operator extras \u2014 43 platforms; new slugs Postiz-aligned verbatim (linkedin-page, instagram-standalone, devto, gmb, warpcast, moltbook, mewe, tumblr); ghost is an operator extra Postiz lacks (docs.postiz.com llms.txt: 34 providers, fetched 2026-09-02). v1.7 (issue #130): kofi \u2014 Ko-fi creator page, operator extra #10, 44 total; creator-monetization family, paste flow (D9). v1.8 (issue #133): buymeacoffee, liberapay, gumroad, payhip \u2014 creator monetization platforms (D16 family), operator extras #11\u2013#14, 48 total; paste flow (D9). v1.9 (issue #137): hotmart \u2014 Hotmart digital-products storefront + memberships (Members Area / recurring billing), operator extra #15, 49 total; creator-monetization family, paste flow (D9)."), "handle": z.string().min(1), "status": z.enum(["claimed", "pending"]), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "note": z.string().optional() }).strict().describe("v1.1 (additive, brand): one row of the brand handle matrix (platform \u2192 handle + claim status). Seed source: the operator's brand-handles availability sweep (\xA717).")).describe("v1.1 (additive, brand): the brand handle matrix \u2014 one row per platform with claim status. Seeded from the operator's brand-handles availability sweep (\xA717).").optional(), "newsletter": z.object({ "platform": z.enum(["substack", "ghost"]).describe("v1.1 (additive, brand): newsletter hosts the engine models (Substack now, Ghost later)."), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole."), "name": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "cadence": z.string().optional() }).strict().describe("v1.1 (additive, brand): the brand's newsletter surface. The name/description double as the GEO-visible text (RSS/LLM-readable).").optional(), "podcast": z.object({ "title": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), "platform": z.enum(["spotify", "rss"]).describe("v1.1 (additive, brand): podcast distribution modeled in v1.1 (Spotify for Creators / standalone RSS).").optional(), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "rss": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "status": z.enum(["planned", "live"]).optional() }).strict().describe("v1.1 (additive, brand): the brand's podcast \u2014 show metadata + RSS (GEO-citable via RSS).").optional(), "press": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "outlet": z.union([z.string().min(1), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]), "title": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole."), "date": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), "artifactRefs": z.array(z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.")).describe("v1.4 (D17/\xA718.1): references `artifacts[].id` \u2014 the work behind this mention. press keeps its domain (no duplication); targets resolved by core (load fails on a dangling ref).").optional() }).strict().describe("v1.1 (additive, brand): a media mention \u2014 outlet, headline, resolvable URL, date (verifiable claims, \xA78).")).describe("v1.1 (additive, brand): media mentions of the brand (verifiable, dated).").optional(), "audiences": z.record(z.string(), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))).describe("v1.1 (additive, brand): brand audiences \u2014 reader profiles (not recruiters). Audience name \u2192 per-lang angle (same shape as positioning.audiences; brand-facing).").optional(), "positioning": z.object({ "statement": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "audiences": z.record(z.string(), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))).describe("Audience name \u2192 per-lang angle (e.g. recruiter / client / academia).").optional(), "geo": z.object({ "semanticAssociations": z.array(z.string()).min(3).max(5).describe("3-5 anchors an LLM should associate with the person (e.g. 'f\xEDsica', 'innovaci\xF3n educativa', 'soberan\xEDa de IA'). Seeded without stuffing (\xA78).").optional(), "citationsGoal": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional() }).strict().optional() }).strict().optional(), "bios": z.array(z.object({ "platform": z.enum(["github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Platform set (ONE source, dedup). v1: person surfaces. v1.1 (additive, brand \xA717): brand surfaces + the socialHandles matrix key space. v1.2 (additive, D16): Postiz parity + operator extras \u2014 43 platforms; new slugs Postiz-aligned verbatim (linkedin-page, instagram-standalone, devto, gmb, warpcast, moltbook, mewe, tumblr); ghost is an operator extra Postiz lacks (docs.postiz.com llms.txt: 34 providers, fetched 2026-09-02). v1.7 (issue #130): kofi \u2014 Ko-fi creator page, operator extra #10, 44 total; creator-monetization family, paste flow (D9). v1.8 (issue #133): buymeacoffee, liberapay, gumroad, payhip \u2014 creator monetization platforms (D16 family), operator extras #11\u2013#14, 48 total; paste flow (D9). v1.9 (issue #137): hotmart \u2014 Hotmart digital-products storefront + memberships (Members Area / recurring billing), operator extra #15, 49 total; creator-monetization family, paste flow (D9)."), "lang": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "length": z.enum(["short", "medium", "long"]), "text": z.string().min(1) }).strict()).describe("Curated bio per platform+lang+length. Generated by `pasco refine`, chosen by the user, stored here.").optional(), "headlines": z.array(z.object({ "platform": z.enum(["github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Platform set (ONE source, dedup). v1: person surfaces. v1.1 (additive, brand \xA717): brand surfaces + the socialHandles matrix key space. v1.2 (additive, D16): Postiz parity + operator extras \u2014 43 platforms; new slugs Postiz-aligned verbatim (linkedin-page, instagram-standalone, devto, gmb, warpcast, moltbook, mewe, tumblr); ghost is an operator extra Postiz lacks (docs.postiz.com llms.txt: 34 providers, fetched 2026-09-02). v1.7 (issue #130): kofi \u2014 Ko-fi creator page, operator extra #10, 44 total; creator-monetization family, paste flow (D9). v1.8 (issue #133): buymeacoffee, liberapay, gumroad, payhip \u2014 creator monetization platforms (D16 family), operator extras #11\u2013#14, 48 total; paste flow (D9). v1.9 (issue #137): hotmart \u2014 Hotmart digital-products storefront + memberships (Members Area / recurring billing), operator extra #15, 49 total; creator-monetization family, paste flow (D9)."), "lang": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "text": z.string().min(1) }).strict()).describe("Headline per platform+lang (the 1-line under the name).").optional(), "skills": z.array(z.object({ "name": z.string().min(1), "level": z.enum(["novice", "intermediate", "advanced", "expert"]).optional(), "keywords": z.array(z.string()).optional(), "geoKeywords": z.array(z.string()).describe("Terms a generative engine should match this skill with (GEO layer, D7).").optional(), "platformWeights": z.record(z.string(), z.number().gte(0).lte(1)).describe("How much this skill matters per platform (0..1). Feeds renderer keyword placement.").optional() }).strict()).optional(), "experience": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "role": z.union([z.string().min(1), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]).describe("v1: string. v1.1 (additive): langMap \u2014 bilingual role titles."), "org": z.string().min(1).describe("Proper noun \u2014 invariant across languages (e.g. 'USFQ', 'Nimbus Labs'). Not a langMap: institutions are never translated (SPEC \xA711)."), "start": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), "end": z.union([z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), z.literal("present")]).optional(), "highlights": z.union([langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), langMapKeys(z.object({ "en": z.array(z.string()) }).catchall(z.array(z.string())))]).describe("v1: langMap of strings (separator-delimited). v1.1 (additive): langMap of string arrays (bullets).").optional() }).strict().describe("start <= end is NOT expressible in JSON Schema \u2014 enforced by core.driftCheck (\xA712 note).")).optional(), "education": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "degree": z.union([z.string().min(1), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]).describe("v1.1: degree title (string or bilingual langMap)."), "institution": z.string().min(1).describe("Proper noun \u2014 invariant across languages (e.g. 'Universidad Central de Venezuela'). Not a langMap: institutions are never translated (SPEC \xA711)."), "field": z.union([z.string(), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]).describe("v1.1: field of study (string or bilingual langMap).").optional(), "start": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), "end": z.union([z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), z.literal("present")]).optional(), "note": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional() }).strict().describe("v1.1 (additive): formal education entry.")).describe("v1.1 (additive): formal education. v1 workspaces keep education inside experience[] with role = degree.").optional(), "languages": z.array(z.object({ "code": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "level": z.enum(["native", "fluent", "advanced", "intermediate", "basic"]) }).strict()).describe("v1.1 (additive): spoken languages. v1 workspaces document them as comments in skills.yaml.").optional(), "projects": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "name": z.string().min(1), "status": z.enum(["active", "maintained", "archived", "experimental", "planning"]).optional(), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "links": z.array(z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.")).optional(), "keywords": z.array(z.string()).optional(), "evidence": z.array(z.object({ "claim": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), "source": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole."), "verified": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "credentialId": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "artifactId": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional() }).strict().describe("Verifiable claim with source. Anti-pattern: unverifiable claims (dropped by cite-aware systems, \xA78).")).optional() }).strict()).optional(), "publications": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "title": z.string().min(1), "venue": z.string().min(1), "year": z.number().int().gte(1900).lte(2100), "authors": z.string().describe("v1.1: author string (e.g. 'Luciani, C.' or 'Zambrano et al.').").optional(), "doi": z.string().regex(new RegExp("^10\\.\\d{4,9}/\\S+$")).optional(), "orcidId": z.string().regex(new RegExp("^\\d{4}-\\d{4}-\\d{4}-\\d{3}[\\dX]$")).optional(), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "artifactRefs": z.array(z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.")).describe("v1.4 (D17/\xA718.1): references `artifacts[].id` \u2014 the dataset/code behind this paper. publications keep their domain (no duplication); targets resolved by core (load fails on a dangling ref).").optional() }).strict().describe("DOI/ORCID must be resolvable \u2014 GEO citability anchor (LLMs resolve them).")).describe("Peer-reviewed / citable output. DOI/ORCID resolvable \u2192 GEO citability.").optional(), "keywords": z.object({ "seo": z.record(z.string(), z.array(z.string())).describe("Per-platform SEO keyword sets (fed by each platform skill's research).").optional(), "geo": z.object({ "associations": z.array(z.string()).optional(), "claims": z.array(z.string()).optional() }).strict().optional() }).strict().optional(), "checks": z.array(z.object({ "id": z.string().optional(), "platform": z.enum(["all", "github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Dedup contract: MUST equal ['all', ...$defs/platform.enum] \u2014 scripts/schema-check asserts it (D16)."), "rule": z.enum(["max-chars", "min-chars", "url-live", "fact-present", "geo-consistent", "lang-present", "link-in-identity", "handle-claimed", "handle-consistent"]).describe("Rule catalog (extensible). Skills' checks.yaml use the same catalog; `pasco check` runs the union. v1.1 (brand \xA717): handle-claimed (a claimed handle must resolve to a URL) and handle-consistent (handle matrix coherent across surfaces)."), "value": z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]), "note": z.string().optional() }).strict()).describe("User-level declarative checks; unioned with the platform skill's checks.yaml by `pasco check`.").optional(), "active": z.record(z.string(), z.object({ "lang": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "variantId": z.string().min(1), "chosenAt": z.string().datetime({ offset: true }) }).strict().describe("The chosen variant id \u2014 user picked it (D11); variant body lives in the workspace variants store.")).describe("Which bio/headline variant is LIVE per platform (D11: user picks, engine records).").optional(), "deployments": z.record(z.string(), z.record(z.string(), z.object({ "deployedAt": z.string().datetime({ offset: true }), "contentHash": z.string().regex(new RegExp("^[a-f0-9]{64}$")), "source": z.enum(["manual", "git", "api"]) }).strict().describe("v1.3 (D18, \xA719.1): one confirmed deploy of a trackable unit on one surface. contentHash = SHA-256 of the canonical serialization (sorted keys, sans id/origin) of the unit AT deploy time \u2014 staleness is exact and release-tag-independent.")).describe("v1.3 (D18): surface \u2192 deploy record. Surface keys are the ONE platform enum (D16) \u2014 also core-checked post-Zod (gen-zod drops propertyNames).")).describe("v1.3 (D18, \xA719): per-entry deploy ledger \u2014 ledgerKey \u2192 surface \u2192 record. Keys are constrained above for JSON Schema consumers; the generated Zod drops propertyNames, so core re-checks key patterns, surface enums, duplicate ids and ambiguous composite keys post-Zod (D12 fail class). Only `pasco` mutates this block (same write rule as refine --choose, D14).").optional(), "credentials": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked."), "kind": z.enum(["certificate", "title", "award", "patent"]).describe("D17/\xA718.2: third-party attestation kind."), "title": z.string().min(1).describe("Credential name (proper noun \u2192 invariant string, \xA711)."), "issuer": z.string().min(1).describe("Third-party attester (proper noun \u2192 invariant string, \xA711)."), "credentialId": z.string().min(1).describe("Issuer-side identifier (e.g. certificate serial). Certificates carry PII \u2014 visibility governs exposure (\xA718.5).").optional(), "verifyUrl": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "issued": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "expires": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "visibility": z.enum(["public", "unlisted", "private"]).describe("D17/\xA718.5: first-class visibility. Renderers emit only `public`; `unlisted` is shared on request (client DMs); `private` never leaves the workspace. Absent = public (the pre-registry profile was public by design \u2014 absence must not hide silently).").optional() }).strict().describe("v1.4 (D17, \xA718.2): a third-party attestation. JSON-LD payoff (\xA718.9): `Person.hasCredential` \u2192 `EducationalOccupationalCredential` (public only).")).describe("v1.4 (D17, \xA718): the proof layer \u2014 third-party attestations (certificates, titles, awards, patents). Pointer-first: the workspace declares pointers, the engine never owns binaries (D14). Naming discipline (\xA718.1): never called evidence.").optional(), "artifacts": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked."), "kind": z.enum(["portfolio", "gallery", "showreel", "dataset", "document", "code"]).describe("D17/\xA718.2: work/media kind."), "title": z.string().min(1).describe("Work title (proper noun \u2192 invariant string, \xA711)."), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "locator": z.object({ "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "path": z.string().min(1).describe("In-workspace `media/\u2026` or external under PASCO_EVIDENCE_ROOT \u2014 capability matrix (\xA718.3): NEVER emitted as a public URL while the workspace is private (D14); reaches public surfaces only via renderer assets/ output (M8).").optional() }).strict().describe("Exactly one of `url` | `path` (\xA718.3 capability matrix). The oneOf expresses the XOR for JSON Schema consumers; the generated Zod cannot express required-without-property, so core re-checks the XOR post-Zod (D12 fail class, ledger precedent)."), "checksum": z.string().regex(new RegExp("^sha256:[a-f0-9]{64}$")).describe("Immutable artifacts only (\xA718.6): recorded at compile for byte-stable bundling (M8).").optional(), "license": z.string().min(1).describe("Usage rights for the work (e.g. CC-BY-4.0).").optional(), "tags": z.array(z.string()).describe("Multi-area selection (\xA718.9): renderers select by platform + lang + tags; one workspace, many areas.").optional(), "visibility": z.enum(["public", "unlisted", "private"]).describe("D17/\xA718.5: first-class visibility. Renderers emit only `public`; `unlisted` is shared on request (client DMs); `private` never leaves the workspace. Absent = public (the pre-registry profile was public by design \u2014 absence must not hide silently).").optional() }).strict().describe("v1.4 (D17, \xA718.2): a work or media item. JSON-LD payoff (\xA718.9): surfaces as CreativeWork/ImageGallery/VideoObject/Dataset per platform (renderer-curated, D5).")).describe("v1.4 (D17, \xA718): works/media items (portfolio, gallery, showreel, dataset, document, code), pointer-first via `locator`. Naming discipline (\xA718.1): never called evidence \u2014 canonical blocks keep their domains (publications/press are NOT registry kinds, they reference registry ids).").optional() }).strict().describe("Contract source (D3) for a profile-data workspace. The core `load()` parses content/*.yaml, merges them into a single Profile and validates against THIS schema. Facts (dates, DOIs, URLs, ORCID) are language-independent and are the GEO anchor. Every text field is a langMap ({lang: value}) with EN required (D8, \xA711). Additive since v1 (semver minor): every earlier workspace still validates. v1.1: role as langMap (org stays invariant string), highlights as string[] per lang, publication.authors/url, root languages + education, identity.handle/timezone, patreon in checks; brand flavor (D15/\xA717) \u2014 `flavor`, org identity variant (orgName/logo/brandVoice/mission/tagline/established), socialHandles/newsletter/podcast/press/audiences blocks, brand platforms, handle checks. New in v1.2 (D16): platform set = Postiz parity + operator extras \u2014 43 platforms, one shared enum for both flavors, single-source dedup guard (check.platform). New in v1.3 (D18): per-entry deploy ledger \u2014 `deployments` (ledgerKey \u2192 surface \u2192 deployedAt/contentHash/source), optional stable `id`s on row blocks (experience/projects/publications/education/press), `origin: template` provenance flag. New in v1.4 (D17): evidence registry \u2014 `credentials[]` (third-party attestations: certificate/title/award/patent) and `artifacts[]` (works/media: portfolio/gallery/showreel/dataset/document/code), pointer-first (\xA718); `evidenceItem` gains `credentialId`/`artifactId` cross-refs, `publications[]`/`press[]` gain `artifactRefs`. Naming discipline (\xA718.1): the registry is never called evidence \u2014 `projects[].evidence[]` stays claims-only. New in v1.5 (issue #71): `identity.email` \u2014 the contact email as a first-class fact (schema.org `email` on Person/Organization), NOT a link \u2014 `links[]` stays http(s)-only (mailto: rejected, D12); a mailto: row in links[] broke the template and every real workspace after v1.4's scheme boundary (issue #53). New in v1.6 (issue #103): `identity.links[].role` \u2014 the link ROLE (`identity` | `realization` | `aggregator`), a translation layer onto schema.org (`url` | `sameAs` | `mainEntityOfPage`); absent role = `realization`, so every pre-v1.6 workspace stays VALIDATING unchanged (the rendered output does change \u2014 see \xA715.4). The JSON-LD `@id` is derived from the `role: identity` link and is ABSENT when no link carries it (never invented). Entity assembly moves to a single home (core/src/structured-data.ts), consumed by geoCheck and every renderer \u2014 the \xA718.9 credentialLdEntries precedent. New in v1.7 (issue #130): `kofi` \u2014 Ko-fi creator page as operator extra #10 (44 platforms), creator-monetization family; paste platform, no API deploy (D9). New in v1.8 (issue #133): `buymeacoffee`, `liberapay`, `gumroad`, `payhip` \u2014 the creator monetization platforms family (D16), operator extras #11\u2013#14 (48 platforms); the family is declared in SPEC and descriptions (docs-first), not as a hard schema field; all four are paste platforms, no API deploy (D9). New in v1.9 (issue #137): `hotmart` \u2014 the Hotmart digital-products storefront + memberships (Members Area / recurring billing) serving the Spanish/Portuguese-speaking markets, in the `creator-monetization` family as operator extra #15 (49 platforms); paste platform, no API deploy (D9) \u2014 no management API is documented for the profile body (developers.hotmart.com covers sales/products [?], fetched 2026-10-05).");
602
+ var LANG_KEYS = ["en", "es", "fr"];
603
+ function langMapKeys(schema) {
604
+ return schema.superRefine((value, ctx) => {
605
+ if (typeof value !== "object" || value === null)
606
+ return;
607
+ for (const key of Object.keys(value)) {
608
+ if (!LANG_KEYS.includes(key)) {
609
+ ctx.addIssue({
610
+ code: "custom",
611
+ path: [key],
612
+ message: `unsupported language key '${key}' (allowed: en, es, fr)`
613
+ });
614
+ }
615
+ }
616
+ });
550
617
  }
551
- function warn(code, message, path, surfaces) {
552
- return { code, severity: "warn", message, path, ...surfaces ? { surfaces } : {} };
618
+
619
+ // ../core/dist/registry.js
620
+ function isPublicEntry(entry) {
621
+ return (entry.visibility ?? "public") === "public";
553
622
  }
554
- var ORCID_PATH = /^\/(\d{4}-\d{4}-\d{4}-\d{3}[\dX])\/?$/;
555
- function identityOrcid(profile) {
556
- for (const link of profile.identity.links ?? []) {
557
- const host = hostOf(link.url);
558
- if (host !== "orcid.org")
623
+ function credentialLdEntries(credentials) {
624
+ return credentials.filter(isPublicEntry).map((c) => ({
625
+ "@type": "EducationalOccupationalCredential",
626
+ ...c.title ? { name: c.title } : {},
627
+ ...c.verifyUrl !== void 0 ? { url: c.verifyUrl } : {},
628
+ ...c.issuer ? { awardedBy: { "@type": "Organization", name: c.issuer } } : {},
629
+ ...c.issued !== void 0 ? { validFrom: c.issued } : {},
630
+ ...c.expires !== void 0 ? { validThrough: c.expires } : {}
631
+ }));
632
+ }
633
+ function integrity(path, message) {
634
+ return { path, message };
635
+ }
636
+ function idsOf(entries) {
637
+ const map = /* @__PURE__ */ new Map();
638
+ for (const [index, entry] of (entries ?? []).entries()) {
639
+ if (entry.id === void 0)
559
640
  continue;
560
- const match = ORCID_PATH.exec(new URL(link.url).pathname);
561
- if (match?.[1])
562
- return match[1];
641
+ map.set(entry.id, index);
563
642
  }
564
- return void 0;
565
- }
566
- function groupBy(items, keyOf) {
567
- const groups = /* @__PURE__ */ new Map();
568
- items.forEach((item, index) => {
569
- const key = keyOf(item);
570
- const bucket = groups.get(key) ?? [];
571
- bucket.push(index);
572
- groups.set(key, bucket);
573
- });
574
- return groups;
643
+ return map;
575
644
  }
576
- function driftCheck(profile) {
645
+ function duplicateIssues(block, entries) {
577
646
  const issues = [];
578
- const identity = profile.identity;
579
- const identityUrls = new Set((identity.links ?? []).map((link) => normalizeUrl(link.url)));
580
- const push = (issue) => issues.push(issue);
581
- const links = identity.links ?? [];
582
- const linkGroups = groupBy(links, (link) => normalizeUrl(link.url));
583
- for (const [url, indices] of linkGroups) {
584
- if (indices.length > 1) {
585
- push(warn("drift/duplicate-identity-link", `identity.links lists "${url}" ${indices.length} times (indices ${indices.join(", ")})`, `identity.links[${indices[0]}]`));
647
+ const seen = /* @__PURE__ */ new Map();
648
+ for (const [index, entry] of (entries ?? []).entries()) {
649
+ if (entry.id === void 0)
650
+ continue;
651
+ const first = seen.get(entry.id);
652
+ if (first !== void 0) {
653
+ issues.push(integrity(`${block}[${index}].id`, `duplicate registry id "${entry.id}" (first used at ${block}[${first}]) \u2014 registry ids are unique per block (load fails, D12 fail class)`));
654
+ } else {
655
+ seen.set(entry.id, index);
586
656
  }
587
657
  }
588
- for (const [projectIndex, project] of (profile.projects ?? []).entries()) {
589
- for (const [linkIndex, url] of (project.links ?? []).entries()) {
590
- if (isPlatformSurface(url) && !identityUrls.has(normalizeUrl(url))) {
591
- push(warn("drift/url-not-in-identity", `URL "${url}" lives on a platform host but is not in identity.links \u2014 add it there (contract rule: URLs only from identity.links)`, `projects[${projectIndex}].links[${linkIndex}]`));
592
- }
658
+ return issues;
659
+ }
660
+ function locatorIssues(entries) {
661
+ const issues = [];
662
+ for (const [index, entry] of (entries ?? []).entries()) {
663
+ const hasUrl = entry.locator?.url !== void 0;
664
+ const hasPath = entry.locator?.path !== void 0;
665
+ if (hasUrl === hasPath) {
666
+ issues.push(integrity(`artifacts[${index}].locator`, `locator must have exactly one of "url" | "path" (\xA718.3 capability matrix) \u2014 got ${hasUrl ? "both" : "neither"}`));
593
667
  }
594
668
  }
595
- const handles = profile.socialHandles ?? [];
596
- if (handles.length > 0) {
597
- const handleGroups = groupBy(handles, (h) => h.platform);
598
- for (const [platform, indices] of handleGroups) {
599
- if (indices.length > 1) {
600
- push(warn("drift/duplicate-handle", `socialHandles lists ${indices.length} rows for platform "${platform}" (indices ${indices.join(", ")}) \u2014 one row per platform, the handle matrix is the SSOT`, `socialHandles[${indices[0]}]`));
601
- }
602
- }
603
- for (const [index, handle] of handles.entries()) {
604
- if (handle.status !== "claimed")
605
- continue;
606
- const hasOwnUrl = handle.url !== void 0;
607
- const resolvedElsewhere = (identity.links ?? []).some((link) => {
608
- const host = hostOf(link.url);
609
- if (host === void 0 || !hostMatchesPlatform(handle.platform, host))
610
- return false;
611
- const path = new URL(link.url).pathname.toLowerCase();
612
- const handleLower = handle.handle.toLowerCase().replace(/^@/, "");
613
- return handleLower.length > 0 && path.includes(handleLower);
614
- });
615
- if (!hasOwnUrl && !resolvedElsewhere) {
616
- push(warn("drift/handle-claimed-not-linked", `claimed handle @${handle.handle} (${handle.platform}) has no url and no matching identity.links entry \u2014 a claimed surface must resolve (handle-claimed rule)`, `socialHandles[${index}]`));
669
+ return issues;
670
+ }
671
+ function refIssues(profile) {
672
+ const credentialIds = idsOf(profile.credentials);
673
+ const artifactIds = idsOf(profile.artifacts);
674
+ const issues = [];
675
+ for (const [pIndex, project] of (profile.projects ?? []).entries()) {
676
+ for (const [eIndex, evidence] of (project.evidence ?? []).entries()) {
677
+ if (evidence.credentialId !== void 0 && !credentialIds.has(evidence.credentialId)) {
678
+ issues.push(integrity(`projects[${pIndex}].evidence[${eIndex}].credentialId`, `references credentials[].id "${evidence.credentialId}" which does not exist (dangling cross-ref, load fails)`));
617
679
  }
618
- if (hasOwnUrl && handle.url !== void 0) {
619
- const host = hostOf(handle.url);
620
- if (host !== void 0 && !hostMatchesPlatform(handle.platform, host)) {
621
- push(warn("drift/handle-host-mismatch", `handle @${handle.handle} (${handle.platform}) points at ${host}, expected ${HANDLE_HOSTS[handle.platform]?.join(" or ") ?? "any"} \u2014 handle-consistent violation (offline proxy)`, `socialHandles[${index}].url`));
622
- }
680
+ if (evidence.artifactId !== void 0 && !artifactIds.has(evidence.artifactId)) {
681
+ issues.push(integrity(`projects[${pIndex}].evidence[${eIndex}].artifactId`, `references artifacts[].id "${evidence.artifactId}" which does not exist (dangling cross-ref, load fails)`));
623
682
  }
624
683
  }
625
684
  }
626
- const experience = profile.experience ?? [];
627
- for (const [index, item] of experience.entries()) {
628
- if (item.end && item.end !== "present" && item.start > item.end) {
629
- push(warn("drift/date-range-inverted", `start (${item.start}) is after end (${item.end}) for "${textOf(item.role)}" at ${item.org}`, `experience[${index}]`));
630
- }
631
- }
632
- const expGroups = groupBy(experience, (item) => `${textOf(item.role).toLowerCase()} @ ${item.org.toLowerCase()}`);
633
- for (const [key, indices] of expGroups) {
634
- if (indices.length < 2)
635
- continue;
636
- const dates = indices.map((i) => `${experience[i]?.start}\u2013${experience[i]?.end ?? "?"}`);
637
- if (new Set(dates).size > 1) {
638
- push(warn("drift/duplicate-experience", `the role "${key}" appears ${indices.length} times with different dates (${dates.join("; ")}) \u2014 same fact written differently across surfaces`, `experience[${indices[0]}]`));
685
+ for (const [pIndex, pub] of (profile.publications ?? []).entries()) {
686
+ for (const [rIndex, ref] of (pub.artifactRefs ?? []).entries()) {
687
+ if (!artifactIds.has(ref)) {
688
+ issues.push(integrity(`publications[${pIndex}].artifactRefs[${rIndex}]`, `references artifacts[].id "${ref}" which does not exist (dangling cross-ref, load fails)`));
689
+ }
639
690
  }
640
691
  }
641
- const bios = profile.bios ?? [];
642
- const bioGroups = groupBy(bios, (b) => `${b.platform}/${b.lang}/${b.length}`);
643
- for (const [key, indices] of bioGroups) {
644
- if (indices.length > 1) {
645
- push(warn("drift/duplicate-bio", `more than one bio for ${key} (indices ${indices.join(", ")}) \u2014 which one is live?`, `bios[${indices[0]}]`));
692
+ for (const [pIndex, press] of (profile.press ?? []).entries()) {
693
+ for (const [rIndex, ref] of (press.artifactRefs ?? []).entries()) {
694
+ if (!artifactIds.has(ref)) {
695
+ issues.push(integrity(`press[${pIndex}].artifactRefs[${rIndex}]`, `references artifacts[].id "${ref}" which does not exist (dangling cross-ref, load fails)`));
696
+ }
646
697
  }
647
698
  }
648
- const headlines = profile.headlines ?? [];
649
- const headlineGroups = groupBy(headlines, (h) => `${h.platform}/${h.lang}`);
650
- for (const [key, indices] of headlineGroups) {
651
- if (indices.length > 1) {
652
- push(warn("drift/duplicate-headline", `more than one headline for ${key} (indices ${indices.join(", ")})`, `headlines[${indices[0]}]`));
699
+ return issues;
700
+ }
701
+ function checkRegistryIntegrity(profile) {
702
+ return [
703
+ ...duplicateIssues("credentials", profile.credentials),
704
+ ...duplicateIssues("artifacts", profile.artifacts),
705
+ ...locatorIssues(profile.artifacts),
706
+ ...refIssues(profile)
707
+ ];
708
+ }
709
+ function todayIso() {
710
+ const now = /* @__PURE__ */ new Date();
711
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
712
+ const dd = String(now.getDate()).padStart(2, "0");
713
+ return `${now.getFullYear()}-${mm}-${dd}`;
714
+ }
715
+ function registryCheck(profile) {
716
+ const findings = [];
717
+ const credentials = profile.credentials ?? [];
718
+ const artifacts = profile.artifacts ?? [];
719
+ const today = todayIso();
720
+ for (const [index, credential] of credentials.entries()) {
721
+ if (credential.expires !== void 0 && credential.expires < today) {
722
+ findings.push({
723
+ code: "check/credential-expired",
724
+ severity: "warn",
725
+ message: `credential "${credential.title}" (${credential.id}) expired ${credential.expires} \u2014 renew or update the workspace`,
726
+ path: `credentials[${index}]`
727
+ });
653
728
  }
654
729
  }
655
- const projects = profile.projects ?? [];
656
- const projectGroups = groupBy(projects, (p) => p.name.toLowerCase());
657
- for (const [name, indices] of projectGroups) {
658
- if (indices.length > 1) {
659
- push(warn("drift/duplicate-project", `project "${name}" appears ${indices.length} times (indices ${indices.join(", ")})`, `projects[${indices[0]}]`));
660
- }
661
- }
662
- const publications = profile.publications ?? [];
663
- const pubGroups = groupBy(publications, (p) => p.title.toLowerCase());
664
- for (const [title, indices] of pubGroups) {
665
- if (indices.length < 2)
666
- continue;
667
- const fingerprints = indices.map((i) => {
668
- const p = publications[i];
669
- return `${p?.year}/${p?.venue}`;
730
+ const nonPublic = (id, kind, list) => {
731
+ if (id === void 0)
732
+ return void 0;
733
+ const entry = list.find((e) => e.id === id);
734
+ if (entry === void 0 || entry.visibility === void 0 || isPublicEntry(entry))
735
+ return void 0;
736
+ return { blockPath: `${kind}[${list.indexOf(entry)}]`, visibility: entry.visibility, id };
737
+ };
738
+ const flagLeak = (leak, refPath, where) => {
739
+ if (leak === void 0)
740
+ return;
741
+ findings.push({
742
+ code: "check/private-in-public-output",
743
+ severity: "error",
744
+ message: `${where} references registry entry "${leak.id}" with visibility "${leak.visibility}" \u2014 public output renders only public entries (\xA718.5); make it public or drop the reference`,
745
+ path: refPath
670
746
  });
671
- if (new Set(fingerprints).size > 1) {
672
- push(warn("drift/publication-conflict", `publication "${title}" appears ${indices.length} times with different year/venue (${fingerprints.join("; ")})`, `publications[${indices[0]}]`));
673
- } else {
674
- push(warn("drift/publication-duplicate", `publication "${title}" (${fingerprints[0]}) is listed ${indices.length} times`, `publications[${indices[0]}]`));
675
- }
676
- }
677
- const orcids = /* @__PURE__ */ new Set();
678
- for (const pub of publications)
679
- if (pub.orcidId)
680
- orcids.add(pub.orcidId);
681
- if (orcids.size > 1) {
682
- push(warn("drift/orcid-conflict", `publications use different ORCIDs (${[...orcids].join(", ")}) \u2014 an LLM triangulating surfaces finds two people`, "publications", [...PLATFORMS]));
683
- }
684
- const identityOrcidId = identityOrcid(profile);
685
- if (identityOrcidId) {
686
- for (const [index, pub] of publications.entries()) {
687
- if (pub.orcidId && pub.orcidId !== identityOrcidId) {
688
- push(warn("drift/orcid-mismatch", `publication[${index}] ORCID ${pub.orcidId} differs from the identity ORCID ${identityOrcidId} (identity.links)`, `publications[${index}]`));
689
- }
747
+ };
748
+ for (const [pIndex, project] of (profile.projects ?? []).entries()) {
749
+ for (const [eIndex, evidence] of (project.evidence ?? []).entries()) {
750
+ flagLeak(nonPublic(evidence.credentialId, "credentials", credentials), `projects[${pIndex}].evidence[${eIndex}].credentialId`, "evidence claim attests a credential");
751
+ flagLeak(nonPublic(evidence.artifactId, "artifacts", artifacts), `projects[${pIndex}].evidence[${eIndex}].artifactId`, "evidence claim proves an artifact");
690
752
  }
691
753
  }
692
- const skills = profile.skills ?? [];
693
- const skillGroups = groupBy(skills, (s) => s.name.toLowerCase());
694
- for (const [name, indices] of skillGroups) {
695
- if (indices.length < 2)
696
- continue;
697
- const levels = new Set(indices.map((i) => skills[i]?.level).filter(Boolean));
698
- if (levels.size > 1) {
699
- push(warn("drift/skill-conflict", `skill "${name}" appears with different levels (${[...levels].join(", ")})`, `skills[${indices[0]}]`));
754
+ for (const [pIndex, pub] of (profile.publications ?? []).entries()) {
755
+ for (const [rIndex, ref] of (pub.artifactRefs ?? []).entries()) {
756
+ flagLeak(nonPublic(ref, "artifacts", artifacts), `publications[${pIndex}].artifactRefs[${rIndex}]`, "publication");
700
757
  }
701
758
  }
702
- const active = profile.active ?? {};
703
- for (const [platformKey, entry] of Object.entries(active)) {
704
- if (!PLATFORMS.includes(platformKey)) {
705
- push(warn("drift/active-platform-unknown", `active["${platformKey}"] is not one of the platform ids (${PLATFORMS.join(", ")})`, `active["${platformKey}"]`));
706
- continue;
707
- }
708
- const platform = platformKey;
709
- const hasBase = bios.some((b) => b.platform === platform && b.lang === entry.lang) || headlines.some((h) => h.platform === platform && h.lang === entry.lang);
710
- if (!hasBase) {
711
- push(warn("drift/active-variant-missing", `active["${platform}"] (variant "${entry.variantId}", ${entry.lang}) has no curated bio/headline for ${platform}/${entry.lang}`, `active["${platform}"]`, [platform]));
759
+ for (const [pIndex, press] of (profile.press ?? []).entries()) {
760
+ for (const [rIndex, ref] of (press.artifactRefs ?? []).entries()) {
761
+ flagLeak(nonPublic(ref, "artifacts", artifacts), `press[${pIndex}].artifactRefs[${rIndex}]`, "press mention");
712
762
  }
713
763
  }
714
- return issues;
764
+ return findings;
715
765
  }
716
766
 
717
- // ../core/dist/geo.js
718
- var CONFLICT_CODES = /* @__PURE__ */ new Set([
719
- "drift/date-range-inverted",
720
- "drift/duplicate-experience",
721
- "drift/duplicate-headline",
722
- "drift/duplicate-bio",
723
- "drift/duplicate-project",
724
- "drift/publication-conflict",
725
- "drift/publication-duplicate",
726
- "drift/orcid-conflict",
727
- "drift/orcid-mismatch",
728
- "drift/skill-conflict",
729
- // brand handle matrix (v1.1/§17) — handle inconsistencies are fact
730
- // contradictions for a brand (an LLM triangulating surfaces must find the
731
- // same handle/URL set).
732
- "drift/duplicate-handle",
733
- "drift/handle-claimed-not-linked",
734
- "drift/handle-host-mismatch"
735
- ]);
736
- function clamp01(value) {
737
- return Math.max(0, Math.min(1, value));
767
+ // ../core/dist/load.js
768
+ var LoadError = class extends Error {
769
+ constructor(message, options) {
770
+ super(message, options);
771
+ this.name = "LoadError";
772
+ }
773
+ };
774
+ var SchemaValidationError = class extends LoadError {
775
+ issues;
776
+ constructor(issues) {
777
+ super(`profile.schema.json validation failed (D12 fail-on-schema) \u2014 ${issues.length} violation(s):
778
+ ` + issues.map((i) => ` ${i.path || "<root>"}: ${i.message}`).join("\n"));
779
+ this.name = "SchemaValidationError";
780
+ this.issues = issues;
781
+ }
782
+ };
783
+ function validateProfile(value) {
784
+ const result = profileSchema.safeParse(value);
785
+ if (!result.success) {
786
+ const issues = result.error.issues.map((issue) => ({
787
+ path: issue.path.length ? issue.path.join(".") : "<root>",
788
+ message: issue.message
789
+ }));
790
+ throw new SchemaValidationError(issues);
791
+ }
792
+ return result.data;
738
793
  }
739
- function langValues(langMap) {
740
- return langMap ? Object.values(langMap).filter((v) => v !== "") : [];
794
+ function normalizeProfile(profile) {
795
+ return {
796
+ ...profile,
797
+ flavor: profile.flavor ?? "person",
798
+ bios: profile.bios ?? [],
799
+ headlines: profile.headlines ?? [],
800
+ skills: profile.skills ?? [],
801
+ experience: profile.experience ?? [],
802
+ projects: profile.projects ?? [],
803
+ publications: profile.publications ?? [],
804
+ checks: profile.checks ?? [],
805
+ socialHandles: profile.socialHandles ?? [],
806
+ press: profile.press ?? [],
807
+ education: profile.education ?? [],
808
+ languages: profile.languages ?? [],
809
+ deployments: profile.deployments ?? {},
810
+ credentials: profile.credentials ?? [],
811
+ artifacts: profile.artifacts ?? []
812
+ };
741
813
  }
742
- function targetedPlatforms(profile) {
743
- const targets = /* @__PURE__ */ new Set();
744
- for (const b of profile.bios ?? [])
745
- targets.add(b.platform);
746
- for (const h of profile.headlines ?? [])
747
- targets.add(h.platform);
748
- for (const key of Object.keys(profile.active ?? {})) {
749
- if (PLATFORMS.includes(key)) {
750
- targets.add(key);
814
+ function readContentFiles(workspace) {
815
+ const contentDir = join(workspace, "content");
816
+ if (!existsSync(contentDir)) {
817
+ throw new LoadError(`workspace has no content/ directory: ${contentDir} (expected content/*.yaml per schema/README.md)`);
818
+ }
819
+ const files = readdirSync(contentDir).filter((file) => /\.ya?ml$/i.test(file) && !file.startsWith(".")).sort();
820
+ const merged = {};
821
+ for (const file of files) {
822
+ const rootKey = file.replace(/\.ya?ml$/i, "");
823
+ const fullPath = join(contentDir, file);
824
+ const doc = parseDocument(readFileSync(fullPath, "utf8"));
825
+ if (doc.errors.length > 0) {
826
+ throw new LoadError(`content/${file}: invalid YAML \u2014 ${doc.errors[0]?.message ?? "parse error"}`);
751
827
  }
828
+ const node = doc.toJS({ maxAliasCount: 100 });
829
+ if (node === null || node === void 0)
830
+ continue;
831
+ if (rootKey in merged) {
832
+ throw new LoadError(`content/${file}: duplicate root property "${rootKey}" (already provided by another content file) \u2014 each root property belongs to exactly one file`);
833
+ }
834
+ merged[rootKey] = node;
752
835
  }
753
- for (const key of Object.keys(profile.keywords?.seo ?? {}))
754
- targets.add(key);
755
- return targets;
836
+ return merged;
756
837
  }
757
- function semanticAnchors(profile) {
758
- const rawAnchors = (profile.positioning?.geo?.semanticAssociations ?? []).map((a) => a.trim()).filter(Boolean);
759
- const anchors = rawAnchors.map((a) => a.toLowerCase());
760
- if (anchors.length === 0)
761
- return { anchors: [], reinforced: [], missing: [], coverage: 0 };
762
- const haystack = [
763
- ...langValues(profile.positioning?.statement),
764
- ...langValues(profile.positioning?.geo?.citationsGoal),
765
- ...profile.keywords?.geo?.associations ?? [],
766
- ...profile.keywords?.geo?.claims ?? []
767
- ];
768
- for (const skill of profile.skills ?? []) {
769
- haystack.push(...skill.geoKeywords ?? [], ...skill.keywords ?? []);
838
+ function load(workspace) {
839
+ const merged = readContentFiles(workspace);
840
+ const profile = validateProfile(merged);
841
+ const ledgerIssues = checkLedgerIntegrity(profile);
842
+ if (ledgerIssues.length > 0) {
843
+ throw new SchemaValidationError(ledgerIssues);
770
844
  }
771
- for (const project of profile.projects ?? []) {
772
- haystack.push(...project.keywords ?? [], ...langValues(project.description));
845
+ const registryIssues = checkRegistryIntegrity(profile);
846
+ if (registryIssues.length > 0) {
847
+ throw new SchemaValidationError(registryIssues);
773
848
  }
774
- const normalized = haystack.map((text) => text.trim().toLowerCase());
775
- const reinforced = [];
776
- const missing = [];
777
- for (let i = 0; i < anchors.length; i++) {
778
- const anchor = anchors[i];
779
- if (!anchor)
780
- continue;
781
- if (normalized.some((text) => text.includes(anchor)))
782
- reinforced.push(rawAnchors[i] ?? anchor);
783
- else
784
- missing.push(rawAnchors[i] ?? anchor);
849
+ return normalizeProfile(profile);
850
+ }
851
+
852
+ // ../core/dist/structured-data.js
853
+ var DEFAULT_LINK_ROLE = "realization";
854
+ function linkRole(link) {
855
+ return link.role ?? DEFAULT_LINK_ROLE;
856
+ }
857
+ function linksWithRole(identity, role) {
858
+ return (identity?.links ?? []).filter((l) => linkRole(l) === role).map((l) => l.url);
859
+ }
860
+ function entityId(identity) {
861
+ return (identity?.links ?? []).find((l) => linkRole(l) === "identity")?.url;
862
+ }
863
+ function sameAsUrls(profile) {
864
+ const identity = profile.identity;
865
+ const claimed = (profile.socialHandles ?? []).filter((h) => h.status === "claimed" && h.url !== void 0).map((h) => h.url);
866
+ const id = entityId(identity);
867
+ return [...linksWithRole(identity, "realization"), ...claimed].filter((url) => url !== id).filter((url, index, all) => all.indexOf(url) === index);
868
+ }
869
+ function currentRole(profile) {
870
+ const items = [...profile.experience ?? []];
871
+ const byStart = (a, b) => b.start.localeCompare(a.start);
872
+ const current = items.filter((item) => item.end === "present").sort(byStart)[0];
873
+ const latest = items.sort(byStart)[0];
874
+ const pick = current ?? latest;
875
+ return pick ? { jobTitle: textOf(pick.role), worksFor: pick.org } : {};
876
+ }
877
+ function hasCredentialLd(profile) {
878
+ const entries = credentialLdEntries(profile.credentials ?? []);
879
+ return entries.length > 0 ? entries : void 0;
880
+ }
881
+ function structuredData(profile, lang = "en") {
882
+ const identity = profile.identity;
883
+ const isBrand = profile.flavor === "brand";
884
+ const credentialsLd = hasCredentialLd(profile);
885
+ const sameAs = sameAsUrls(profile);
886
+ const entityIdValue = entityId(identity);
887
+ if (isBrand) {
888
+ const slogan = identity.tagline?.[lang] || identity.tagline?.en || void 0;
889
+ const description = identity.mission?.[lang] || identity.mission?.en || void 0;
890
+ const fields2 = {
891
+ name: Boolean(identity.name),
892
+ sameAs,
893
+ ...entityIdValue !== void 0 ? { id: entityIdValue } : {},
894
+ ...identity.email !== void 0 ? { email: identity.email } : {},
895
+ ...identity.logo ? { logo: identity.logo } : {},
896
+ ...identity.established ? { foundingDate: identity.established } : {},
897
+ ...slogan ? { slogan } : {},
898
+ ...description ? { description } : {},
899
+ ...identity.location ? { address: identity.location } : {}
900
+ };
901
+ const considered2 = [
902
+ "name",
903
+ "sameAs",
904
+ "email",
905
+ "logo",
906
+ "foundingDate",
907
+ "slogan",
908
+ "description",
909
+ "address"
910
+ ];
911
+ const populated2 = 1 + (sameAs.length > 0 ? 1 : 0) + (identity.email !== void 0 ? 1 : 0) + (identity.logo ? 1 : 0) + (identity.established ? 1 : 0) + (slogan ? 1 : 0) + (description ? 1 : 0) + (identity.location ? 1 : 0);
912
+ const ready2 = Boolean(identity.name);
913
+ const jsonLd2 = identity.name ? {
914
+ "@context": "https://schema.org",
915
+ "@type": "Organization",
916
+ ...entityIdValue !== void 0 ? { "@id": entityIdValue } : {},
917
+ name: identity.name,
918
+ ...identity.email !== void 0 ? { email: identity.email } : {},
919
+ ...identity.logo ? { logo: identity.logo } : {},
920
+ ...identity.established ? { foundingDate: identity.established } : {},
921
+ ...slogan ? { slogan } : {},
922
+ ...description ? { description } : {},
923
+ sameAs,
924
+ ...credentialsLd ? { hasCredential: credentialsLd } : {}
925
+ } : void 0;
926
+ return {
927
+ entityType: "Organization",
928
+ ready: ready2,
929
+ completeness: populated2 / considered2.length,
930
+ fields: fields2,
931
+ ...jsonLd2 ? { jsonLd: jsonLd2 } : {}
932
+ };
785
933
  }
934
+ const role = currentRole(profile);
935
+ const fields = {
936
+ name: Boolean(identity.name),
937
+ sameAs,
938
+ ...entityIdValue !== void 0 ? { id: entityIdValue } : {},
939
+ ...identity.email !== void 0 ? { email: identity.email } : {},
940
+ ...identity.photo ? { image: identity.photo } : {},
941
+ ...identity.location ? { address: identity.location } : {},
942
+ ...role.jobTitle ? { jobTitle: role.jobTitle } : {},
943
+ ...role.worksFor ? { worksFor: role.worksFor } : {}
944
+ };
945
+ const considered = ["name", "sameAs", "email", "image", "address", "jobTitle", "worksFor"];
946
+ const populated = 1 + (sameAs.length > 0 ? 1 : 0) + (identity.email !== void 0 ? 1 : 0) + (identity.photo ? 1 : 0) + (identity.location ? 1 : 0) + (role.jobTitle ? 1 : 0) + (role.worksFor ? 1 : 0);
947
+ const ready = Boolean(identity.name);
948
+ const jsonLd = identity.name ? {
949
+ "@context": "https://schema.org",
950
+ "@type": "Person",
951
+ ...entityIdValue !== void 0 ? { "@id": entityIdValue } : {},
952
+ name: identity.name,
953
+ ...identity.email !== void 0 ? { email: identity.email } : {},
954
+ ...identity.photo ? { image: identity.photo } : {},
955
+ ...identity.location ? { address: identity.location } : {},
956
+ ...role.jobTitle ? { jobTitle: role.jobTitle } : {},
957
+ ...role.worksFor ? { worksFor: role.worksFor } : {},
958
+ sameAs,
959
+ ...credentialsLd ? { hasCredential: credentialsLd } : {}
960
+ } : void 0;
786
961
  return {
787
- anchors: rawAnchors,
788
- reinforced,
789
- missing,
790
- coverage: anchors.length === 0 ? 0 : reinforced.length / anchors.length
962
+ entityType: "Person",
963
+ ready,
964
+ completeness: populated / considered.length,
965
+ fields,
966
+ ...jsonLd ? { jsonLd } : {}
791
967
  };
792
968
  }
793
- function identityAuthorityDetail(profile) {
794
- const identity = profile.identity;
795
- const declared = linksWithRole(identity, "identity");
796
- if (declared.length > 0) {
797
- return "no @id resolved for this profile's declared role: identity link";
798
- }
799
- const aggregators = linksWithRole(identity, "aggregator");
800
- if (aggregators.length > 0) {
801
- return `no role: identity link \u2014 ${aggregators.length} aggregator link(s) declared (${aggregators.join(", ")}) are NOT eligible for @id: an aggregator lists references to the entity, it does not describe it. Mark your own page (or profile) role: identity instead.`;
969
+
970
+ // ../core/dist/compile.js
971
+ var LANG_IDS = ["en", "es", "fr"];
972
+ function compile(profile, platform, lang = "en", policy) {
973
+ if (!PLATFORMS.includes(platform)) {
974
+ throw new TypeError(`compile: unknown platform "${String(platform)}" (allowed: ${PLATFORMS.join(", ")})`);
802
975
  }
803
- const realizations = linksWithRole(identity, "realization");
804
- if (realizations.length > 0) {
805
- return `no role: identity link \u2014 all ${realizations.length} link(s) are role: realization (they indicate the entity, not are it). Mark the one that represents you role: identity to make the entity citable.`;
976
+ if (!LANG_IDS.includes(lang)) {
977
+ throw new TypeError(`compile: unknown language "${String(lang)}" (allowed: ${LANG_IDS.join(", ")})`);
806
978
  }
807
- return "no links declared \u2014 add the page that represents this entity with role: identity to make it citable";
808
- }
809
- function geoCheck(profile) {
810
- const identity = profile.identity;
811
- const conflicts = driftCheck(profile).filter((issue) => CONFLICT_CODES.has(issue.code));
812
- const targets = targetedPlatforms(profile);
813
- const targetsWithBio = [...targets].filter((platform) => (profile.bios ?? []).some((b) => b.platform === platform)).length;
814
- const targetsWithHeadline = [...targets].filter((platform) => (profile.headlines ?? []).some((h) => h.platform === platform)).length;
815
- const targetCount = targets.size;
816
- const sd = structuredData(profile);
817
- const anchors = semanticAnchors(profile);
818
- const publications = profile.publications ?? [];
819
- const citablePublications = publications.filter((p) => p.doi || p.orcidId).length;
820
- const projects = profile.projects ?? [];
821
- const projectsWithEvidence = projects.filter((p) => (p.evidence ?? []).length > 0).length;
822
- const evidenceWithVerified = projects.flatMap((p) => p.evidence ?? []).filter((e) => Boolean(e.verified)).length;
823
- const totalEvidence = projects.flatMap((p) => p.evidence ?? []).length;
824
- const checks = [
825
- {
826
- code: "geo/conflicts",
827
- label: "No fact contradictions across surfaces",
828
- weight: 0.25,
829
- score: clamp01(1 - conflicts.length * 0.25),
830
- detail: conflicts.length === 0 ? "no fact contradictions detected" : `${conflicts.length} contradiction(s): ${[...new Set(conflicts.map((c) => c.code))].join(", ")}`
831
- },
832
- {
833
- code: "geo/platform-bio-coverage",
834
- label: "Every targeted platform has a bio",
835
- weight: 0.12,
836
- score: targetCount === 0 ? 1 : targetsWithBio / targetCount,
837
- detail: targetCount === 0 ? "no platforms targeted yet" : `${targetsWithBio}/${targetCount} platforms with a bio (${[...targets].filter((p) => !(profile.bios ?? []).some((b) => b.platform === p)).join(", ") || "all"} missing)`
838
- },
839
- {
840
- code: "geo/platform-headline-coverage",
841
- label: "Every targeted platform has a headline",
842
- weight: 0.08,
843
- score: targetCount === 0 ? 1 : targetsWithHeadline / targetCount,
844
- detail: targetCount === 0 ? "no platforms targeted yet" : `${targetsWithHeadline}/${targetCount} platforms with a headline`
845
- },
846
- {
847
- code: "geo/entity-id",
848
- label: "Canonical entity identifier (schema.org @id)",
849
- // Shares the entity-identity budget with `geo/structured-person` below: both score
850
- // the SAME thing (the entity's own structured data), so the pair totals the 0.10
851
- // that field-check alone used to carry. Weights sum to 1 by contract — the test
852
- // asserts it, and a new check must pay for itself out of an existing budget.
853
- weight: 0.05,
854
- // SPEC §15.4 / #103: the `@id` comes from the `role: identity` link and is never
855
- // invented. Absent = the profile declares no canonical realization, never silently
856
- // satisfied by a heuristic.
857
- //
858
- // HONEST ACCOUNTING (v1.6 review): this check is NOT score-neutral. Every workspace
859
- // that does not yet declare `role: identity` loses up to 5 points (weight 0.05) —
860
- // the largest single v1.6 delta. It is a *reported gap*, not a regression: the `@id`
861
- // was never emitted before, so nothing that worked stopped working. But it does mean
862
- // upgrading moves the GEO score, and a user comparing before/after deserves to know.
863
- score: sd.fields.id !== void 0 ? 1 : 0,
864
- detail: sd.fields.id !== void 0 ? `@id = ${sd.fields.id} (from the role: identity link)` : identityAuthorityDetail(profile)
865
- },
866
- {
867
- code: "geo/sameas-links",
868
- label: "Canonical identity links (schema.org sameAs)",
869
- weight: 0.15,
870
- // Counts `role: realization` links — the ones that actually become `sameAs`.
871
- // Counting identity/aggregator links here would credit a profile whose sameAs
872
- // set is empty, i.e. score a signal it does not emit.
873
- //
874
- // The rationale applies in BOTH directions (v1.6 review, F9): it counts only
875
- // `links[]`, while the emitted `sameAs` also carries claimed `socialHandles[].url`.
876
- // A profile can therefore reach 5/5 here with fewer than 5 emitted URLs (or score
877
- // under 5 with more) — this check measures the SSOT-declared realization set, not
878
- // the JSON-LD array. Kept as-is deliberately: handles are a brand-flavor construct
879
- // (§17.1) and counting them would make a person and a brand score differently for
880
- // the same citability. Documented rather than silently asymmetric.
881
- score: clamp01(linksWithRole(identity, "realization").length / 5),
882
- detail: `${linksWithRole(identity, "realization").length} link(s) with role: realization in identity.links (\u22653 recommended for GEO citability)`
883
- },
884
- {
885
- code: "geo/structured-person",
886
- label: "Entity fields beyond the minimum (photo/location/jobTitle \u2014 logo/description/foundingDate for brand)",
887
- // Paired with `geo/entity-id` above: the two together carry the entity-identity
888
- // budget that this check alone held before v1.6 (#103).
889
- weight: 0.05,
890
- // Read from sd.fields — structuredData is the single source of truth for
891
- // entity-field presence (issue #86, F1): Boolean(entity) on the raw object
892
- // credited `mission: {en: ""}` as present while structuredData (falsy en-read)
893
- // called it absent. Parity holds for every schema-valid populated value:
894
- // logo/established/photo are url/date-typed (non-empty), location is a plain
895
- // string where "" is falsy in both readings.
896
- score: profile.flavor === "brand" ? (Number(Boolean(sd.fields.logo)) + Number(Boolean(sd.fields.description)) + Number(Boolean(sd.fields.foundingDate))) / 3 : (Number(Boolean(sd.fields.image)) + Number(Boolean(sd.fields.address)) + Number(Boolean(sd.fields.jobTitle))) / 3,
897
- detail: profile.flavor === "brand" ? `logo: ${sd.fields.logo ? "yes" : "no"} \xB7 description(mission): ${sd.fields.description ? "yes" : "no"} \xB7 foundingDate: ${sd.fields.foundingDate ? "yes" : "no"}` : `photo: ${sd.fields.image ? "yes" : "no"} \xB7 location: ${sd.fields.address ? "yes" : "no"} \xB7 jobTitle: ${sd.fields.jobTitle ? "yes" : "no"}`
898
- },
899
- {
900
- code: "geo/semantic-anchors",
901
- label: "Semantic association anchors reinforced across surfaces",
902
- weight: 0.15,
903
- score: anchors.anchors.length === 0 ? 0 : anchors.reinforced.length / anchors.anchors.length,
904
- detail: anchors.anchors.length === 0 ? "no positioning.geo.semanticAssociations defined (3\u20135 anchors)" : `${anchors.reinforced.length}/${anchors.anchors.length} anchors reinforced; missing: ${anchors.missing.join(", ") || "none"}`
905
- },
906
- {
907
- code: "geo/citable-publications",
908
- label: "Publications resolvable via DOI/ORCID",
909
- weight: 0.05,
910
- score: publications.length === 0 ? 1 : citablePublications / publications.length,
911
- detail: publications.length === 0 ? "no publications" : `${citablePublications}/${publications.length} publications carry a DOI or ORCID`
912
- },
913
- {
914
- code: "geo/evidence-claims",
915
- label: "Projects carry verifiable evidence[]",
916
- weight: 0.1,
917
- score: projects.length === 0 ? 1 : projectsWithEvidence / projects.length,
918
- detail: projects.length === 0 ? "no projects" : `${projectsWithEvidence}/${projects.length} projects with evidence; ${evidenceWithVerified}/${totalEvidence} evidence claims have a verified date`
979
+ const fallbacks = [];
980
+ const langText = (langMap, path) => {
981
+ const direct = langMap?.[lang] || void 0;
982
+ if (direct !== void 0)
983
+ return direct;
984
+ const en = langMap?.en || void 0;
985
+ if (en !== void 0) {
986
+ fallbacks.push(path);
987
+ return en;
919
988
  }
920
- ];
921
- const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
922
- const score = Math.round(checks.reduce((sum, check) => sum + check.score * check.weight, 0) / totalWeight * 100);
923
- const section2 = (label, detail, subset) => {
924
- const weight = subset.reduce((sum, check) => sum + check.weight, 0);
925
- const sectionScore = Math.round(subset.reduce((sum, check) => sum + check.score * check.weight, 0) / weight * 100);
926
- return { label, detail, checks: subset, score: sectionScore };
989
+ return void 0;
927
990
  };
928
- const sections = {
929
- factConsistency: section2("Fact consistency", "An LLM triangulating platforms must find the same dates/roles/papers.", checks.filter((c) => c.code.startsWith("geo/conflicts") || c.code.startsWith("geo/platform-"))),
930
- structuredData: {
931
- ...section2(
932
- "Structured-data readiness",
933
- // Mirrors `ready = Boolean(name)` exactly (structured-data.ts). The old string still
934
- // claimed "need name + ≥1 sameAs link" — the predicate it no longer uses, i.e. the
935
- // same contradiction as the `ready` bug, one line away from its own fix.
936
- `schema.org ${sd.entityType} JSON-LD ${sd.ready ? "ready for the web renderer" : "not ready (need a name)"}.`,
937
- checks.filter((c) => c.code.startsWith("geo/sameas") || c.code.startsWith("geo/structured") || c.code === "geo/entity-id")
938
- ),
939
- readiness: sd
940
- },
941
- semanticAssociations: {
942
- ...section2("Semantic associations", "The 3\u20135 anchors an LLM should associate with the person, reinforced across surfaces.", checks.filter((c) => c.code === "geo/semantic-anchors")),
943
- anchors
991
+ const positioning = profile.positioning;
992
+ const audiences = {};
993
+ for (const [name, map] of Object.entries(positioning?.audiences ?? {})) {
994
+ const text = langText(map, `positioning.audiences.${name}`);
995
+ if (text !== void 0)
996
+ audiences[name] = text;
997
+ }
998
+ const brandAudiences = {};
999
+ for (const [name, map] of Object.entries(profile.audiences ?? {})) {
1000
+ const text = langText(map, `audiences.${name}`);
1001
+ if (text !== void 0)
1002
+ brandAudiences[name] = text;
1003
+ }
1004
+ const platformChecks = (profile.checks ?? []).filter((c) => c.platform === "all" || c.platform === platform);
1005
+ return {
1006
+ platform,
1007
+ lang,
1008
+ flavor: profile.flavor ?? "person",
1009
+ // facts — untouched
1010
+ identity: profile.identity,
1011
+ experience: profile.experience ?? [],
1012
+ projects: profile.projects ?? [],
1013
+ publications: profile.publications ?? [],
1014
+ skills: profile.skills ?? [],
1015
+ socialHandles: profile.socialHandles ?? [],
1016
+ newsletter: profile.newsletter,
1017
+ podcast: profile.podcast,
1018
+ press: profile.press ?? [],
1019
+ brandAudiences,
1020
+ // language-aware text
1021
+ bios: (profile.bios ?? []).filter((b) => b.platform === platform && b.lang === lang),
1022
+ headlines: (profile.headlines ?? []).filter((h) => h.platform === platform && h.lang === lang),
1023
+ statement: langText(positioning?.statement, "positioning.statement"),
1024
+ audiences,
1025
+ citationsGoal: langText(positioning?.geo?.citationsGoal, "positioning.geo.citationsGoal"),
1026
+ keywords: {
1027
+ seo: profile.keywords?.seo?.[platform] ?? [],
1028
+ geo: profile.keywords?.geo ?? {}
944
1029
  },
945
- citability: section2("Citability", "Verifiable claims: resolvable DOIs/ORCIDs and evidence[] with sources.", checks.filter((c) => c.code.startsWith("geo/citable") || c.code.startsWith("geo/evidence")))
1030
+ checks: platformChecks,
1031
+ // Evidence registry (v1.4/D17, §18) — facts, pass through untouched.
1032
+ credentials: profile.credentials ?? [],
1033
+ artifacts: profile.artifacts ?? [],
1034
+ ...profile.active?.[platform] ? { active: profile.active[platform] } : {},
1035
+ // v1.6 (#103): entity structured-data identity — assembled ONCE here, so no
1036
+ // renderer re-derives `@id`/`sameAs` (SPEC §15.4, the §18.9 precedent).
1037
+ structuredData: structuredData(profile, lang),
1038
+ // Issue #102: the completeness report travels WITH the slice (like
1039
+ // structuredData) so a renderer emits the number verbatim and never
1040
+ // recomputes it. `policy` is the workspace's `.pascorc` policy when the CLI
1041
+ // passes it, so the artifact and `pasco status --completeness` agree
1042
+ // (review of #205, finding 3); omitted ⇒ the shipped default.
1043
+ completeness: completeness(normalizeProfile(profile), policy),
1044
+ fallbacks
946
1045
  };
947
- return { score, sections, checks };
948
1046
  }
949
1047
 
950
- // ../core/dist/ledger.js
951
- import { createHash } from "crypto";
952
- var LEDGER_META = /* @__PURE__ */ new Set(["id", "origin"]);
953
- function canonicalJson(value) {
954
- const sort = (v) => {
955
- if (Array.isArray(v))
956
- return v.map(sort);
957
- if (v !== null && typeof v === "object") {
958
- const out = {};
959
- for (const key of Object.keys(v).sort()) {
960
- out[key] = sort(v[key]);
961
- }
962
- return out;
963
- }
964
- return v;
965
- };
966
- return JSON.stringify(sort(value));
1048
+ // ../core/dist/drift.js
1049
+ function hostMatchesPlatform(platform, host) {
1050
+ const hosts = HANDLE_HOSTS[platform];
1051
+ if (hosts === void 0)
1052
+ return true;
1053
+ if (hosts.includes(host))
1054
+ return true;
1055
+ const subdomainHosts = PLATFORM_SUBDOMAIN_HOSTS[platform];
1056
+ if (subdomainHosts?.some((h) => host === h || host.endsWith(`.${h}`)))
1057
+ return true;
1058
+ return false;
967
1059
  }
968
- function hashUnit(unit) {
969
- const stripped = {};
970
- if (unit !== null && typeof unit === "object" && !Array.isArray(unit)) {
971
- for (const [key, value] of Object.entries(unit)) {
972
- if (!LEDGER_META.has(key))
973
- stripped[key] = value;
974
- }
1060
+ function hostOf(url) {
1061
+ try {
1062
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
1063
+ } catch {
1064
+ return void 0;
975
1065
  }
976
- return createHash("sha256").update(canonicalJson(stripped)).digest("hex");
977
1066
  }
978
- var LEDGER_KEY_PATTERN = /^(identity|positioning|keywords)$|^(bios|headlines|skills|socialHandles|experience|projects|publications|education|press):.+$/;
979
- var ROW_BLOCKS = ["experience", "projects", "publications", "education", "press"];
980
- var ROW_CONFIG = {
981
- experience: {
982
- block: "experience",
983
- label: (r) => `${textOf(r.role)} @ ${String(r.org ?? "")}`
984
- },
985
- projects: { block: "projects", label: (r) => String(r.name ?? "") },
986
- publications: { block: "publications", label: (r) => String(r.title ?? "") },
987
- education: {
988
- block: "education",
989
- label: (r) => `${textOf(r.degree)} @ ${String(r.institution ?? "")}`
990
- },
991
- press: { block: "press", label: (r) => textOf(r.title) }
992
- };
993
- function trackableUnits(profile) {
994
- const units = [];
995
- const push = (key, kind, block, label, unit, origin = "user") => {
996
- units.push({ key, kind, block, label, origin, hash: hashUnit(unit) });
997
- };
998
- for (const block of ["identity", "positioning", "keywords"]) {
999
- const value = profile[block];
1000
- if (value !== void 0)
1001
- push(block, "root", block, block, value);
1002
- }
1003
- for (const bio of profile.bios) {
1004
- push(`bios:${bio.platform}:${bio.lang}:${bio.length}`, "bio", "bios", `${bio.platform} ${bio.lang}/${bio.length}`, bio);
1005
- }
1006
- for (const h of profile.headlines) {
1007
- push(`headlines:${h.platform}:${h.lang}`, "headline", "headlines", `${h.platform} ${h.lang}`, h);
1067
+ function normalizeUrl(url) {
1068
+ return url.trim().replace(/\/+$/, "");
1069
+ }
1070
+ function isPlatformSurface(url) {
1071
+ const host = hostOf(url);
1072
+ if (!host)
1073
+ return false;
1074
+ if (PLATFORM_HOSTS.includes(host))
1075
+ return true;
1076
+ return Object.values(PLATFORM_SUBDOMAIN_HOSTS).flat().some((h) => host.endsWith(`.${h}`));
1077
+ }
1078
+ function warn(code, message, path, surfaces) {
1079
+ return { code, severity: "warn", message, path, ...surfaces ? { surfaces } : {} };
1080
+ }
1081
+ var ORCID_PATH = /^\/(\d{4}-\d{4}-\d{4}-\d{3}[\dX])\/?$/;
1082
+ function identityOrcid(profile) {
1083
+ for (const link of profile.identity.links ?? []) {
1084
+ const host = hostOf(link.url);
1085
+ if (host !== "orcid.org")
1086
+ continue;
1087
+ const match = ORCID_PATH.exec(new URL(link.url).pathname);
1088
+ if (match?.[1])
1089
+ return match[1];
1008
1090
  }
1009
- for (const s of profile.skills) {
1010
- push(`skills:${s.name}`, "skill", "skills", s.name, s);
1091
+ return void 0;
1092
+ }
1093
+ function groupBy(items, keyOf) {
1094
+ const groups = /* @__PURE__ */ new Map();
1095
+ items.forEach((item, index) => {
1096
+ const key = keyOf(item);
1097
+ const bucket = groups.get(key) ?? [];
1098
+ bucket.push(index);
1099
+ groups.set(key, bucket);
1100
+ });
1101
+ return groups;
1102
+ }
1103
+ function driftCheck(profile) {
1104
+ const issues = [];
1105
+ const identity = profile.identity;
1106
+ const identityUrls = new Set((identity.links ?? []).map((link) => normalizeUrl(link.url)));
1107
+ const push = (issue) => issues.push(issue);
1108
+ const links = identity.links ?? [];
1109
+ const linkGroups = groupBy(links, (link) => normalizeUrl(link.url));
1110
+ for (const [url, indices] of linkGroups) {
1111
+ if (indices.length > 1) {
1112
+ push(warn("drift/duplicate-identity-link", `identity.links lists "${url}" ${indices.length} times (indices ${indices.join(", ")})`, `identity.links[${indices[0]}]`));
1113
+ }
1011
1114
  }
1012
- for (const h of profile.socialHandles) {
1013
- push(`socialHandles:${h.platform}`, "handle", "socialHandles", `${h.platform} @${h.handle}`, h);
1115
+ for (const [projectIndex, project] of (profile.projects ?? []).entries()) {
1116
+ for (const [linkIndex, url] of (project.links ?? []).entries()) {
1117
+ if (isPlatformSurface(url) && !identityUrls.has(normalizeUrl(url))) {
1118
+ push(warn("drift/url-not-in-identity", `URL "${url}" lives on a platform host but is not in identity.links \u2014 add it there (contract rule: URLs only from identity.links)`, `projects[${projectIndex}].links[${linkIndex}]`));
1119
+ }
1120
+ }
1014
1121
  }
1015
- for (const { block } of Object.values(ROW_CONFIG)) {
1016
- const rows = profile[block];
1017
- if (!Array.isArray(rows))
1018
- continue;
1019
- for (const raw of rows) {
1020
- const row = raw;
1021
- if (typeof row.id !== "string" || row.id === "")
1122
+ const handles = profile.socialHandles ?? [];
1123
+ if (handles.length > 0) {
1124
+ const handleGroups = groupBy(handles, (h) => h.platform);
1125
+ for (const [platform, indices] of handleGroups) {
1126
+ if (indices.length > 1) {
1127
+ push(warn("drift/duplicate-handle", `socialHandles lists ${indices.length} rows for platform "${platform}" (indices ${indices.join(", ")}) \u2014 one row per platform, the handle matrix is the SSOT`, `socialHandles[${indices[0]}]`));
1128
+ }
1129
+ }
1130
+ for (const [index, handle] of handles.entries()) {
1131
+ if (handle.status !== "claimed")
1022
1132
  continue;
1023
- const origin = row.origin === "template" ? "template" : "user";
1024
- push(`${block}:${row.id}`, "row", block, ROW_CONFIG[block].label(row), row, origin);
1133
+ const hasOwnUrl = handle.url !== void 0;
1134
+ const resolvedElsewhere = (identity.links ?? []).some((link) => {
1135
+ const host = hostOf(link.url);
1136
+ if (host === void 0 || !hostMatchesPlatform(handle.platform, host))
1137
+ return false;
1138
+ const path = new URL(link.url).pathname.toLowerCase();
1139
+ const handleLower = handle.handle.toLowerCase().replace(/^@/, "");
1140
+ return handleLower.length > 0 && path.includes(handleLower);
1141
+ });
1142
+ if (!hasOwnUrl && !resolvedElsewhere) {
1143
+ push(warn("drift/handle-claimed-not-linked", `claimed handle @${handle.handle} (${handle.platform}) has no url and no matching identity.links entry \u2014 a claimed surface must resolve (handle-claimed rule)`, `socialHandles[${index}]`));
1144
+ }
1145
+ if (hasOwnUrl && handle.url !== void 0) {
1146
+ const host = hostOf(handle.url);
1147
+ if (host !== void 0 && !hostMatchesPlatform(handle.platform, host)) {
1148
+ push(warn("drift/handle-host-mismatch", `handle @${handle.handle} (${handle.platform}) points at ${host}, expected ${HANDLE_HOSTS[handle.platform]?.join(" or ") ?? "any"} \u2014 handle-consistent violation (offline proxy)`, `socialHandles[${index}].url`));
1149
+ }
1150
+ }
1025
1151
  }
1026
1152
  }
1027
- return units;
1028
- }
1029
- function untrackedRows(profile) {
1030
- const out = [];
1031
- for (const { block } of Object.values(ROW_CONFIG)) {
1032
- const rows = profile[block];
1033
- if (!Array.isArray(rows))
1153
+ const experience = profile.experience ?? [];
1154
+ for (const [index, item] of experience.entries()) {
1155
+ if (item.end && item.end !== "present" && item.start > item.end) {
1156
+ push(warn("drift/date-range-inverted", `start (${item.start}) is after end (${item.end}) for "${textOf(item.role)}" at ${item.org}`, `experience[${index}]`));
1157
+ }
1158
+ }
1159
+ const expGroups = groupBy(experience, (item) => `${textOf(item.role).toLowerCase()} @ ${item.org.toLowerCase()}`);
1160
+ for (const [key, indices] of expGroups) {
1161
+ if (indices.length < 2)
1034
1162
  continue;
1035
- for (const raw of rows) {
1036
- const row = raw;
1037
- if (typeof row.id !== "string" || row.id === "") {
1038
- out.push({ block, label: ROW_CONFIG[block].label(row) });
1039
- }
1163
+ const dates = indices.map((i) => `${experience[i]?.start}\u2013${experience[i]?.end ?? "?"}`);
1164
+ if (new Set(dates).size > 1) {
1165
+ push(warn("drift/duplicate-experience", `the role "${key}" appears ${indices.length} times with different dates (${dates.join("; ")}) \u2014 same fact written differently across surfaces`, `experience[${indices[0]}]`));
1040
1166
  }
1041
1167
  }
1042
- return out;
1043
- }
1044
- function deployStatus(profile) {
1045
- const units = trackableUnits(profile);
1046
- const ledger = profile.deployments ?? {};
1047
- const byKey = new Set(units.map((u) => u.key));
1048
- const removed = [];
1049
- for (const [key, surfaces] of Object.entries(ledger)) {
1050
- if (!byKey.has(key)) {
1051
- removed.push({ key, surfaces: Object.keys(surfaces).sort() });
1168
+ const bios = profile.bios ?? [];
1169
+ const bioGroups = groupBy(bios, (b) => `${b.platform}/${b.lang}/${b.length}`);
1170
+ for (const [key, indices] of bioGroups) {
1171
+ if (indices.length > 1) {
1172
+ push(warn("drift/duplicate-bio", `more than one bio for ${key} (indices ${indices.join(", ")}) \u2014 which one is live?`, `bios[${indices[0]}]`));
1052
1173
  }
1053
1174
  }
1054
- const statuses = units.map((unit) => {
1055
- const records = ledger[unit.key] ?? {};
1056
- const surfaces = {};
1057
- let stale = false;
1058
- let deployed = 0;
1059
- for (const [surface, record] of Object.entries(records)) {
1060
- deployed += 1;
1061
- const inSync = record.contentHash === unit.hash;
1062
- surfaces[surface] = inSync ? "in-sync" : "stale";
1063
- if (!inSync)
1064
- stale = true;
1175
+ const headlines = profile.headlines ?? [];
1176
+ const headlineGroups = groupBy(headlines, (h) => `${h.platform}/${h.lang}`);
1177
+ for (const [key, indices] of headlineGroups) {
1178
+ if (indices.length > 1) {
1179
+ push(warn("drift/duplicate-headline", `more than one headline for ${key} (indices ${indices.join(", ")})`, `headlines[${indices[0]}]`));
1065
1180
  }
1066
- return {
1067
- ...unit,
1068
- surfaces,
1069
- state: stale ? "stale" : deployed > 0 ? "in-sync" : "never"
1070
- };
1071
- });
1072
- return { units: statuses, untracked: untrackedRows(profile), removed };
1073
- }
1074
- function checkLedgerIntegrity(profile) {
1075
- const issues = [];
1076
- const root = profile;
1077
- for (const block of ROW_BLOCKS) {
1078
- const rows = root[block];
1079
- if (!Array.isArray(rows))
1181
+ }
1182
+ const projects = profile.projects ?? [];
1183
+ const projectGroups = groupBy(projects, (p) => p.name.toLowerCase());
1184
+ for (const [name, indices] of projectGroups) {
1185
+ if (indices.length > 1) {
1186
+ push(warn("drift/duplicate-project", `project "${name}" appears ${indices.length} times (indices ${indices.join(", ")})`, `projects[${indices[0]}]`));
1187
+ }
1188
+ }
1189
+ const publications = profile.publications ?? [];
1190
+ const pubGroups = groupBy(publications, (p) => p.title.toLowerCase());
1191
+ for (const [title, indices] of pubGroups) {
1192
+ if (indices.length < 2)
1080
1193
  continue;
1081
- const seen = /* @__PURE__ */ new Map();
1082
- rows.forEach((raw, index) => {
1083
- const id = raw.id;
1084
- if (typeof id !== "string" || id === "")
1085
- return;
1086
- const count = seen.get(id) ?? 0;
1087
- if (count > 0) {
1088
- issues.push({
1089
- path: `${block}[${index}].id`,
1090
- message: `duplicate id "${id}" in ${block} \u2014 entry ids must be unique per block (D18 \xA719.2)`
1091
- });
1092
- }
1093
- seen.set(id, count + 1);
1194
+ const fingerprints = indices.map((i) => {
1195
+ const p = publications[i];
1196
+ return `${p?.year}/${p?.venue}`;
1094
1197
  });
1095
- }
1096
- const ledger = root.deployments;
1097
- if (ledger === void 0)
1098
- return issues;
1099
- for (const key of Object.keys(ledger)) {
1100
- if (!LEDGER_KEY_PATTERN.test(key)) {
1101
- issues.push({
1102
- path: `deployments.${key}`,
1103
- message: `"${key}" is not a valid ledger key (D18 \xA719.2 key space: root block names or block:key composite)`
1104
- });
1198
+ if (new Set(fingerprints).size > 1) {
1199
+ push(warn("drift/publication-conflict", `publication "${title}" appears ${indices.length} times with different year/venue (${fingerprints.join("; ")})`, `publications[${indices[0]}]`));
1200
+ } else {
1201
+ push(warn("drift/publication-duplicate", `publication "${title}" (${fingerprints[0]}) is listed ${indices.length} times`, `publications[${indices[0]}]`));
1105
1202
  }
1106
1203
  }
1107
- const platforms = new Set(PLATFORMS);
1108
- for (const [key, surfaces] of Object.entries(ledger)) {
1109
- for (const surface of Object.keys(surfaces)) {
1110
- if (!platforms.has(surface)) {
1111
- issues.push({
1112
- path: `deployments.${key}.${surface}`,
1113
- message: `surface "${surface}" is not in the platform set (D16)`
1114
- });
1204
+ const orcids = /* @__PURE__ */ new Set();
1205
+ for (const pub of publications)
1206
+ if (pub.orcidId)
1207
+ orcids.add(pub.orcidId);
1208
+ if (orcids.size > 1) {
1209
+ push(warn("drift/orcid-conflict", `publications use different ORCIDs (${[...orcids].join(", ")}) \u2014 an LLM triangulating surfaces finds two people`, "publications", [...PLATFORMS]));
1210
+ }
1211
+ const identityOrcidId = identityOrcid(profile);
1212
+ if (identityOrcidId) {
1213
+ for (const [index, pub] of publications.entries()) {
1214
+ if (pub.orcidId && pub.orcidId !== identityOrcidId) {
1215
+ push(warn("drift/orcid-mismatch", `publication[${index}] ORCID ${pub.orcidId} differs from the identity ORCID ${identityOrcidId} (identity.links)`, `publications[${index}]`));
1115
1216
  }
1116
1217
  }
1117
1218
  }
1118
- const compositeCount = (prefix, key, rows) => {
1119
- if (!Array.isArray(rows))
1120
- return;
1121
- const counts = /* @__PURE__ */ new Map();
1122
- for (const raw of rows) {
1123
- const k = key(raw);
1124
- counts.set(k, (counts.get(k) ?? 0) + 1);
1219
+ const skills = profile.skills ?? [];
1220
+ const skillGroups = groupBy(skills, (s) => s.name.toLowerCase());
1221
+ for (const [name, indices] of skillGroups) {
1222
+ if (indices.length < 2)
1223
+ continue;
1224
+ const levels = new Set(indices.map((i) => skills[i]?.level).filter(Boolean));
1225
+ if (levels.size > 1) {
1226
+ push(warn("drift/skill-conflict", `skill "${name}" appears with different levels (${[...levels].join(", ")})`, `skills[${indices[0]}]`));
1125
1227
  }
1126
- for (const [key2, count] of counts) {
1127
- if (count > 1 && ledger[`${prefix}:${key2}`] !== void 0) {
1128
- issues.push({
1129
- path: `deployments.${prefix}:${key2}`,
1130
- message: `ambiguous ledger key "${prefix}:${key2}" \u2014 ${count} rows share this natural key; disambiguate before tracking (D18 \xA719.2)`
1131
- });
1132
- }
1228
+ }
1229
+ const active = profile.active ?? {};
1230
+ for (const [platformKey, entry] of Object.entries(active)) {
1231
+ if (!PLATFORMS.includes(platformKey)) {
1232
+ push(warn("drift/active-platform-unknown", `active["${platformKey}"] is not one of the platform ids (${PLATFORMS.join(", ")})`, `active["${platformKey}"]`));
1233
+ continue;
1133
1234
  }
1134
- };
1135
- compositeCount("bios", (r) => `${r.platform}:${r.lang}:${r.length}`, root.bios);
1136
- compositeCount("headlines", (r) => `${r.platform}:${r.lang}`, root.headlines);
1137
- compositeCount("skills", (r) => String(r.name), root.skills);
1138
- compositeCount("socialHandles", (r) => String(r.platform), root.socialHandles);
1235
+ const platform = platformKey;
1236
+ const hasBase = bios.some((b) => b.platform === platform && b.lang === entry.lang) || headlines.some((h) => h.platform === platform && h.lang === entry.lang);
1237
+ if (!hasBase) {
1238
+ push(warn("drift/active-variant-missing", `active["${platform}"] (variant "${entry.variantId}", ${entry.lang}) has no curated bio/headline for ${platform}/${entry.lang}`, `active["${platform}"]`, [platform]));
1239
+ }
1240
+ }
1139
1241
  return issues;
1140
1242
  }
1141
1243
 
1142
- // ../core/dist/load.js
1143
- import { existsSync, readdirSync, readFileSync } from "fs";
1144
- import { join } from "path";
1145
- import { parseDocument } from "yaml";
1146
-
1147
- // ../core/dist/generated/schema.zod.js
1148
- import { z } from "zod";
1149
- var profileSchema = z.object({ "identity": z.object({ "name": z.string().min(1), "email": z.string().regex(new RegExp(`^[^@\\s"']+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$`)).describe("v1.5 (additive, issue #71): contact email of the person/brand (schema.org `email` on Person/Organization). A fact like name/location \u2014 NOT a link: renderers emit it as a mailto: href in the Links section and as the JSON-LD `email` property; `sameAs` stays page-URLs-only. Lives OUTSIDE identity.links[] by design \u2014 the ^https?:// rule of $defs/url (issue #53) stays strict on every URL field, so a mailto: row in links[] fails validation (D12). RFC-5322-adjacent (issue #71 review): local part lax (no whitespace/quotes), domain hostname-shaped (labels [A-Za-z0-9-], no empty/trailing/consecutive dots \u2014 unicode domains must use punycode).").optional(), "handle": z.string().describe("v1.1: primary handle (e.g. X @cluciani_ve).").optional(), "timezone": z.string().describe("v1.1: IANA timezone (e.g. America/Guayaquil).").optional(), "orgName": z.string().min(1).describe("v1.1 (additive, brand): legal/registered org name when it differs from the display name (name = display, e.g. 'Topia Press').").optional(), "logo": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "brandVoice": z.object({ "tone": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "values": z.array(z.string()).optional(), "avoid": z.array(z.string()).optional() }).strict().describe("v1.1 (additive, brand): tone matrix seed \u2014 overall tone (langMap), core values, words/tones to avoid. Per-platform voice refinement lives in the platform skill (\xA717).").optional(), "mission": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "tagline": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "established": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "location": z.string().optional(), "photo": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "links": z.array(z.object({ "platform": z.string().optional(), "label": z.string().optional(), "role": z.enum(["identity", "realization", "aggregator"]).describe("v1.6 (#103): the ROLE this link plays with respect to the entity \u2014 a translation layer of this engine onto schema.org, never standard vocabulary. `identity` \u2192 schema.org:url (the page IS the entity; strong authority; the one eligible for JSON-LD @id). `realization` (DEFAULT when absent) \u2192 schema.org:sameAs (the page indirectly indicates the entity; weak authority; these \u2014 and only these \u2014 form the sameAs set). `aggregator` \u2192 schema.org:mainEntityOfPage (link-in-bio / redirector that aggregates references to the entity; medium authority; never eligible for @id). Absence keeps every pre-v1.6 workspace VALIDATING unchanged (additive, dev-methodology \xA73) \u2014 it does NOT keep the output byte-identical: v1.6 also makes github-readme emit jobTitle/worksFor, narrows sameAs to realization links (+ claimed handles), and adds the geo/entity-id gap report.").optional(), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.") }).strict()).describe("Canonical link set. Renderers and skills read URLs ONLY from here \u2014 no per-platform URL duplication (drift source). v1.6 (#103): items carry an optional `role` distinguishing the entity's own page (schema.org:url) from pages that merely indicate it (schema.org:sameAs) or aggregate references to it (schema.org:mainEntityOfPage).").optional() }).strict(), "flavor": z.enum(["person", "brand"]).describe("v1.1 (additive): workspace flavor (D15). person (default, absent = person) or brand. Brand enables the org identity variant, the socialHandles/newsletter/podcast/press/audiences blocks and Organization GEO (\xA717).").optional(), "socialHandles": z.array(z.object({ "platform": z.enum(["github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Platform set (ONE source, dedup). v1: person surfaces. v1.1 (additive, brand \xA717): brand surfaces + the socialHandles matrix key space. v1.2 (additive, D16): Postiz parity + operator extras \u2014 43 platforms; new slugs Postiz-aligned verbatim (linkedin-page, instagram-standalone, devto, gmb, warpcast, moltbook, mewe, tumblr); ghost is an operator extra Postiz lacks (docs.postiz.com llms.txt: 34 providers, fetched 2026-09-02). v1.7 (issue #130): kofi \u2014 Ko-fi creator page, operator extra #10, 44 total; creator-monetization family, paste flow (D9). v1.8 (issue #133): buymeacoffee, liberapay, gumroad, payhip \u2014 creator monetization platforms (D16 family), operator extras #11\u2013#14, 48 total; paste flow (D9). v1.9 (issue #137): hotmart \u2014 Hotmart digital-products storefront + memberships (Members Area / recurring billing), operator extra #15, 49 total; creator-monetization family, paste flow (D9)."), "handle": z.string().min(1), "status": z.enum(["claimed", "pending"]), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "note": z.string().optional() }).strict().describe("v1.1 (additive, brand): one row of the brand handle matrix (platform \u2192 handle + claim status). Seed source: the operator's brand-handles availability sweep (\xA717).")).describe("v1.1 (additive, brand): the brand handle matrix \u2014 one row per platform with claim status. Seeded from the operator's brand-handles availability sweep (\xA717).").optional(), "newsletter": z.object({ "platform": z.enum(["substack", "ghost"]).describe("v1.1 (additive, brand): newsletter hosts the engine models (Substack now, Ghost later)."), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole."), "name": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "cadence": z.string().optional() }).strict().describe("v1.1 (additive, brand): the brand's newsletter surface. The name/description double as the GEO-visible text (RSS/LLM-readable).").optional(), "podcast": z.object({ "title": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), "platform": z.enum(["spotify", "rss"]).describe("v1.1 (additive, brand): podcast distribution modeled in v1.1 (Spotify for Creators / standalone RSS).").optional(), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "rss": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "status": z.enum(["planned", "live"]).optional() }).strict().describe("v1.1 (additive, brand): the brand's podcast \u2014 show metadata + RSS (GEO-citable via RSS).").optional(), "press": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "outlet": z.union([z.string().min(1), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]), "title": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole."), "date": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), "artifactRefs": z.array(z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.")).describe("v1.4 (D17/\xA718.1): references `artifacts[].id` \u2014 the work behind this mention. press keeps its domain (no duplication); targets resolved by core (load fails on a dangling ref).").optional() }).strict().describe("v1.1 (additive, brand): a media mention \u2014 outlet, headline, resolvable URL, date (verifiable claims, \xA78).")).describe("v1.1 (additive, brand): media mentions of the brand (verifiable, dated).").optional(), "audiences": z.record(z.string(), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))).describe("v1.1 (additive, brand): brand audiences \u2014 reader profiles (not recruiters). Audience name \u2192 per-lang angle (same shape as positioning.audiences; brand-facing).").optional(), "positioning": z.object({ "statement": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "audiences": z.record(z.string(), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))).describe("Audience name \u2192 per-lang angle (e.g. recruiter / client / academia).").optional(), "geo": z.object({ "semanticAssociations": z.array(z.string()).min(3).max(5).describe("3-5 anchors an LLM should associate with the person (e.g. 'f\xEDsica', 'innovaci\xF3n educativa', 'soberan\xEDa de IA'). Seeded without stuffing (\xA78).").optional(), "citationsGoal": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional() }).strict().optional() }).strict().optional(), "bios": z.array(z.object({ "platform": z.enum(["github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Platform set (ONE source, dedup). v1: person surfaces. v1.1 (additive, brand \xA717): brand surfaces + the socialHandles matrix key space. v1.2 (additive, D16): Postiz parity + operator extras \u2014 43 platforms; new slugs Postiz-aligned verbatim (linkedin-page, instagram-standalone, devto, gmb, warpcast, moltbook, mewe, tumblr); ghost is an operator extra Postiz lacks (docs.postiz.com llms.txt: 34 providers, fetched 2026-09-02). v1.7 (issue #130): kofi \u2014 Ko-fi creator page, operator extra #10, 44 total; creator-monetization family, paste flow (D9). v1.8 (issue #133): buymeacoffee, liberapay, gumroad, payhip \u2014 creator monetization platforms (D16 family), operator extras #11\u2013#14, 48 total; paste flow (D9). v1.9 (issue #137): hotmart \u2014 Hotmart digital-products storefront + memberships (Members Area / recurring billing), operator extra #15, 49 total; creator-monetization family, paste flow (D9)."), "lang": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "length": z.enum(["short", "medium", "long"]), "text": z.string().min(1) }).strict()).describe("Curated bio per platform+lang+length. Generated by `pasco refine`, chosen by the user, stored here.").optional(), "headlines": z.array(z.object({ "platform": z.enum(["github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Platform set (ONE source, dedup). v1: person surfaces. v1.1 (additive, brand \xA717): brand surfaces + the socialHandles matrix key space. v1.2 (additive, D16): Postiz parity + operator extras \u2014 43 platforms; new slugs Postiz-aligned verbatim (linkedin-page, instagram-standalone, devto, gmb, warpcast, moltbook, mewe, tumblr); ghost is an operator extra Postiz lacks (docs.postiz.com llms.txt: 34 providers, fetched 2026-09-02). v1.7 (issue #130): kofi \u2014 Ko-fi creator page, operator extra #10, 44 total; creator-monetization family, paste flow (D9). v1.8 (issue #133): buymeacoffee, liberapay, gumroad, payhip \u2014 creator monetization platforms (D16 family), operator extras #11\u2013#14, 48 total; paste flow (D9). v1.9 (issue #137): hotmart \u2014 Hotmart digital-products storefront + memberships (Members Area / recurring billing), operator extra #15, 49 total; creator-monetization family, paste flow (D9)."), "lang": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "text": z.string().min(1) }).strict()).describe("Headline per platform+lang (the 1-line under the name).").optional(), "skills": z.array(z.object({ "name": z.string().min(1), "level": z.enum(["novice", "intermediate", "advanced", "expert"]).optional(), "keywords": z.array(z.string()).optional(), "geoKeywords": z.array(z.string()).describe("Terms a generative engine should match this skill with (GEO layer, D7).").optional(), "platformWeights": z.record(z.string(), z.number().gte(0).lte(1)).describe("How much this skill matters per platform (0..1). Feeds renderer keyword placement.").optional() }).strict()).optional(), "experience": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "role": z.union([z.string().min(1), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]).describe("v1: string. v1.1 (additive): langMap \u2014 bilingual role titles."), "org": z.string().min(1).describe("Proper noun \u2014 invariant across languages (e.g. 'USFQ', 'Nimbus Labs'). Not a langMap: institutions are never translated (SPEC \xA711)."), "start": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), "end": z.union([z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), z.literal("present")]).optional(), "highlights": z.union([langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), langMapKeys(z.object({ "en": z.array(z.string()) }).catchall(z.array(z.string())))]).describe("v1: langMap of strings (separator-delimited). v1.1 (additive): langMap of string arrays (bullets).").optional() }).strict().describe("start <= end is NOT expressible in JSON Schema \u2014 enforced by core.driftCheck (\xA712 note).")).optional(), "education": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "degree": z.union([z.string().min(1), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]).describe("v1.1: degree title (string or bilingual langMap)."), "institution": z.string().min(1).describe("Proper noun \u2014 invariant across languages (e.g. 'Universidad Central de Venezuela'). Not a langMap: institutions are never translated (SPEC \xA711)."), "field": z.union([z.string(), langMapKeys(z.object({ "en": z.string() }).catchall(z.string()))]).describe("v1.1: field of study (string or bilingual langMap).").optional(), "start": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), "end": z.union([z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01)."), z.literal("present")]).optional(), "note": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional() }).strict().describe("v1.1 (additive): formal education entry.")).describe("v1.1 (additive): formal education. v1 workspaces keep education inside experience[] with role = degree.").optional(), "languages": z.array(z.object({ "code": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "level": z.enum(["native", "fluent", "advanced", "intermediate", "basic"]) }).strict()).describe("v1.1 (additive): spoken languages. v1 workspaces document them as comments in skills.yaml.").optional(), "projects": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "name": z.string().min(1), "status": z.enum(["active", "maintained", "archived", "experimental", "planning"]).optional(), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "links": z.array(z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.")).optional(), "keywords": z.array(z.string()).optional(), "evidence": z.array(z.object({ "claim": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())), "source": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole."), "verified": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "credentialId": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "artifactId": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional() }).strict().describe("Verifiable claim with source. Anti-pattern: unverifiable claims (dropped by cite-aware systems, \xA78).")).optional() }).strict()).optional(), "publications": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.").optional(), "origin": z.literal("template").describe("v1.3 (D18, \xA719.2): provenance \u2014 `template` marks the fictional examples shipped by profile-data-template. Absent = user-authored.").optional(), "title": z.string().min(1), "venue": z.string().min(1), "year": z.number().int().gte(1900).lte(2100), "authors": z.string().describe("v1.1: author string (e.g. 'Luciani, C.' or 'Zambrano et al.').").optional(), "doi": z.string().regex(new RegExp("^10\\.\\d{4,9}/\\S+$")).optional(), "orcidId": z.string().regex(new RegExp("^\\d{4}-\\d{4}-\\d{4}-\\d{3}[\\dX]$")).optional(), "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "artifactRefs": z.array(z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked.")).describe("v1.4 (D17/\xA718.1): references `artifacts[].id` \u2014 the dataset/code behind this paper. publications keep their domain (no duplication); targets resolved by core (load fails on a dangling ref).").optional() }).strict().describe("DOI/ORCID must be resolvable \u2014 GEO citability anchor (LLMs resolve them).")).describe("Peer-reviewed / citable output. DOI/ORCID resolvable \u2192 GEO citability.").optional(), "keywords": z.object({ "seo": z.record(z.string(), z.array(z.string())).describe("Per-platform SEO keyword sets (fed by each platform skill's research).").optional(), "geo": z.object({ "associations": z.array(z.string()).optional(), "claims": z.array(z.string()).optional() }).strict().optional() }).strict().optional(), "checks": z.array(z.object({ "id": z.string().optional(), "platform": z.enum(["all", "github-readme", "linkedin", "upwork", "x", "researchgate", "substack", "patreon", "github-org", "bluesky", "instagram", "tiktok", "youtube", "spotify", "reddit", "mastodon", "soundcloud", "facebook", "linkedin-page", "instagram-standalone", "warpcast", "lemmy", "discord", "slack", "skool", "whop", "pinterest", "dribbble", "medium", "devto", "hashnode", "wordpress", "gmb", "listmonk", "twitch", "threads", "telegram", "nostr", "vk", "kick", "moltbook", "ghost", "mewe", "tumblr", "kofi", "buymeacoffee", "liberapay", "gumroad", "payhip", "hotmart"]).describe("Dedup contract: MUST equal ['all', ...$defs/platform.enum] \u2014 scripts/schema-check asserts it (D16)."), "rule": z.enum(["max-chars", "min-chars", "url-live", "fact-present", "geo-consistent", "lang-present", "link-in-identity", "handle-claimed", "handle-consistent"]).describe("Rule catalog (extensible). Skills' checks.yaml use the same catalog; `pasco check` runs the union. v1.1 (brand \xA717): handle-claimed (a claimed handle must resolve to a URL) and handle-consistent (handle matrix coherent across surfaces)."), "value": z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]), "note": z.string().optional() }).strict()).describe("User-level declarative checks; unioned with the platform skill's checks.yaml by `pasco check`.").optional(), "active": z.record(z.string(), z.object({ "lang": z.enum(["en", "es", "fr"]).describe("Supported languages. EN required (international recruiters), ES native, FR desirable (D8)."), "variantId": z.string().min(1), "chosenAt": z.string().datetime({ offset: true }) }).strict().describe("The chosen variant id \u2014 user picked it (D11); variant body lives in the workspace variants store.")).describe("Which bio/headline variant is LIVE per platform (D11: user picks, engine records).").optional(), "deployments": z.record(z.string(), z.record(z.string(), z.object({ "deployedAt": z.string().datetime({ offset: true }), "contentHash": z.string().regex(new RegExp("^[a-f0-9]{64}$")), "source": z.enum(["manual", "git", "api"]) }).strict().describe("v1.3 (D18, \xA719.1): one confirmed deploy of a trackable unit on one surface. contentHash = SHA-256 of the canonical serialization (sorted keys, sans id/origin) of the unit AT deploy time \u2014 staleness is exact and release-tag-independent.")).describe("v1.3 (D18): surface \u2192 deploy record. Surface keys are the ONE platform enum (D16) \u2014 also core-checked post-Zod (gen-zod drops propertyNames).")).describe("v1.3 (D18, \xA719): per-entry deploy ledger \u2014 ledgerKey \u2192 surface \u2192 record. Keys are constrained above for JSON Schema consumers; the generated Zod drops propertyNames, so core re-checks key patterns, surface enums, duplicate ids and ambiguous composite keys post-Zod (D12 fail class). Only `pasco` mutates this block (same write rule as refine --choose, D14).").optional(), "credentials": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked."), "kind": z.enum(["certificate", "title", "award", "patent"]).describe("D17/\xA718.2: third-party attestation kind."), "title": z.string().min(1).describe("Credential name (proper noun \u2192 invariant string, \xA711)."), "issuer": z.string().min(1).describe("Third-party attester (proper noun \u2192 invariant string, \xA711)."), "credentialId": z.string().min(1).describe("Issuer-side identifier (e.g. certificate serial). Certificates carry PII \u2014 visibility governs exposure (\xA718.5).").optional(), "verifyUrl": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "issued": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "expires": z.string().date().describe("YYYY-MM-DD. Facts are language-independent (GEO anchor). Month/year precision allowed (YYYY-MM via YYYY-MM-01).").optional(), "visibility": z.enum(["public", "unlisted", "private"]).describe("D17/\xA718.5: first-class visibility. Renderers emit only `public`; `unlisted` is shared on request (client DMs); `private` never leaves the workspace. Absent = public (the pre-registry profile was public by design \u2014 absence must not hide silently).").optional() }).strict().describe("v1.4 (D17, \xA718.2): a third-party attestation. JSON-LD payoff (\xA718.9): `Person.hasCredential` \u2192 `EducationalOccupationalCredential` (public only).")).describe("v1.4 (D17, \xA718): the proof layer \u2014 third-party attestations (certificates, titles, awards, patents). Pointer-first: the workspace declares pointers, the engine never owns binaries (D14). Naming discipline (\xA718.1): never called evidence.").optional(), "artifacts": z.array(z.object({ "id": z.string().regex(new RegExp("^[a-z0-9][a-z0-9._:-]*$")).min(1).describe("v1.3 (D18, \xA719.2): stable entry id \u2014 the ledger address. Unique per block (core-checked, fails validation; JSON Schema cannot express uniqueness). Optional: rows without id are listed by `pasco status` as untracked."), "kind": z.enum(["portfolio", "gallery", "showreel", "dataset", "document", "code"]).describe("D17/\xA718.2: work/media kind."), "title": z.string().min(1).describe("Work title (proper noun \u2192 invariant string, \xA711)."), "description": langMapKeys(z.object({ "en": z.string() }).catchall(z.string())).optional(), "locator": z.object({ "url": z.string().url().regex(new RegExp("^https?://")).min(7).describe("http(s) ONLY (issue #53): the SSOT validates the scheme at the boundary so renderers emit any SSOT URL verbatim with zero per-emit filtering. `format: uri` alone admits non-http schemes (javascript:, data:, vbscript:) \u2014 the pattern is the assertion; format stays as annotation for schema-aware consumers. Case-sensitive per ECMA-262 regex (JSON Schema patterns carry no flags); an uppercase scheme fails loudly (D12 fail class), it is not a security hole.").optional(), "path": z.string().min(1).describe("In-workspace `media/\u2026` or external under PASCO_EVIDENCE_ROOT \u2014 capability matrix (\xA718.3): NEVER emitted as a public URL while the workspace is private (D14); reaches public surfaces only via renderer assets/ output (M8).").optional() }).strict().describe("Exactly one of `url` | `path` (\xA718.3 capability matrix). The oneOf expresses the XOR for JSON Schema consumers; the generated Zod cannot express required-without-property, so core re-checks the XOR post-Zod (D12 fail class, ledger precedent)."), "checksum": z.string().regex(new RegExp("^sha256:[a-f0-9]{64}$")).describe("Immutable artifacts only (\xA718.6): recorded at compile for byte-stable bundling (M8).").optional(), "license": z.string().min(1).describe("Usage rights for the work (e.g. CC-BY-4.0).").optional(), "tags": z.array(z.string()).describe("Multi-area selection (\xA718.9): renderers select by platform + lang + tags; one workspace, many areas.").optional(), "visibility": z.enum(["public", "unlisted", "private"]).describe("D17/\xA718.5: first-class visibility. Renderers emit only `public`; `unlisted` is shared on request (client DMs); `private` never leaves the workspace. Absent = public (the pre-registry profile was public by design \u2014 absence must not hide silently).").optional() }).strict().describe("v1.4 (D17, \xA718.2): a work or media item. JSON-LD payoff (\xA718.9): surfaces as CreativeWork/ImageGallery/VideoObject/Dataset per platform (renderer-curated, D5).")).describe("v1.4 (D17, \xA718): works/media items (portfolio, gallery, showreel, dataset, document, code), pointer-first via `locator`. Naming discipline (\xA718.1): never called evidence \u2014 canonical blocks keep their domains (publications/press are NOT registry kinds, they reference registry ids).").optional() }).strict().describe("Contract source (D3) for a profile-data workspace. The core `load()` parses content/*.yaml, merges them into a single Profile and validates against THIS schema. Facts (dates, DOIs, URLs, ORCID) are language-independent and are the GEO anchor. Every text field is a langMap ({lang: value}) with EN required (D8, \xA711). Additive since v1 (semver minor): every earlier workspace still validates. v1.1: role as langMap (org stays invariant string), highlights as string[] per lang, publication.authors/url, root languages + education, identity.handle/timezone, patreon in checks; brand flavor (D15/\xA717) \u2014 `flavor`, org identity variant (orgName/logo/brandVoice/mission/tagline/established), socialHandles/newsletter/podcast/press/audiences blocks, brand platforms, handle checks. New in v1.2 (D16): platform set = Postiz parity + operator extras \u2014 43 platforms, one shared enum for both flavors, single-source dedup guard (check.platform). New in v1.3 (D18): per-entry deploy ledger \u2014 `deployments` (ledgerKey \u2192 surface \u2192 deployedAt/contentHash/source), optional stable `id`s on row blocks (experience/projects/publications/education/press), `origin: template` provenance flag. New in v1.4 (D17): evidence registry \u2014 `credentials[]` (third-party attestations: certificate/title/award/patent) and `artifacts[]` (works/media: portfolio/gallery/showreel/dataset/document/code), pointer-first (\xA718); `evidenceItem` gains `credentialId`/`artifactId` cross-refs, `publications[]`/`press[]` gain `artifactRefs`. Naming discipline (\xA718.1): the registry is never called evidence \u2014 `projects[].evidence[]` stays claims-only. New in v1.5 (issue #71): `identity.email` \u2014 the contact email as a first-class fact (schema.org `email` on Person/Organization), NOT a link \u2014 `links[]` stays http(s)-only (mailto: rejected, D12); a mailto: row in links[] broke the template and every real workspace after v1.4's scheme boundary (issue #53). New in v1.6 (issue #103): `identity.links[].role` \u2014 the link ROLE (`identity` | `realization` | `aggregator`), a translation layer onto schema.org (`url` | `sameAs` | `mainEntityOfPage`); absent role = `realization`, so every pre-v1.6 workspace stays VALIDATING unchanged (the rendered output does change \u2014 see \xA715.4). The JSON-LD `@id` is derived from the `role: identity` link and is ABSENT when no link carries it (never invented). Entity assembly moves to a single home (core/src/structured-data.ts), consumed by geoCheck and every renderer \u2014 the \xA718.9 credentialLdEntries precedent. New in v1.7 (issue #130): `kofi` \u2014 Ko-fi creator page as operator extra #10 (44 platforms), creator-monetization family; paste platform, no API deploy (D9). New in v1.8 (issue #133): `buymeacoffee`, `liberapay`, `gumroad`, `payhip` \u2014 the creator monetization platforms family (D16), operator extras #11\u2013#14 (48 platforms); the family is declared in SPEC and descriptions (docs-first), not as a hard schema field; all four are paste platforms, no API deploy (D9). New in v1.9 (issue #137): `hotmart` \u2014 the Hotmart digital-products storefront + memberships (Members Area / recurring billing) serving the Spanish/Portuguese-speaking markets, in the `creator-monetization` family as operator extra #15 (49 platforms); paste platform, no API deploy (D9) \u2014 no management API is documented for the profile body (developers.hotmart.com covers sales/products [?], fetched 2026-10-05).");
1150
- var LANG_KEYS = ["en", "es", "fr"];
1151
- function langMapKeys(schema) {
1152
- return schema.superRefine((value, ctx) => {
1153
- if (typeof value !== "object" || value === null)
1154
- return;
1155
- for (const key of Object.keys(value)) {
1156
- if (!LANG_KEYS.includes(key)) {
1157
- ctx.addIssue({
1158
- code: "custom",
1159
- path: [key],
1160
- message: `unsupported language key '${key}' (allowed: en, es, fr)`
1161
- });
1162
- }
1163
- }
1164
- });
1244
+ // ../core/dist/geo.js
1245
+ var CONFLICT_CODES = /* @__PURE__ */ new Set([
1246
+ "drift/date-range-inverted",
1247
+ "drift/duplicate-experience",
1248
+ "drift/duplicate-headline",
1249
+ "drift/duplicate-bio",
1250
+ "drift/duplicate-project",
1251
+ "drift/publication-conflict",
1252
+ "drift/publication-duplicate",
1253
+ "drift/orcid-conflict",
1254
+ "drift/orcid-mismatch",
1255
+ "drift/skill-conflict",
1256
+ // brand handle matrix (v1.1/§17) — handle inconsistencies are fact
1257
+ // contradictions for a brand (an LLM triangulating surfaces must find the
1258
+ // same handle/URL set).
1259
+ "drift/duplicate-handle",
1260
+ "drift/handle-claimed-not-linked",
1261
+ "drift/handle-host-mismatch"
1262
+ ]);
1263
+ function clamp01(value) {
1264
+ return Math.max(0, Math.min(1, value));
1165
1265
  }
1166
-
1167
- // ../core/dist/load.js
1168
- var LoadError = class extends Error {
1169
- constructor(message, options) {
1170
- super(message, options);
1171
- this.name = "LoadError";
1172
- }
1173
- };
1174
- var SchemaValidationError = class extends LoadError {
1175
- issues;
1176
- constructor(issues) {
1177
- super(`profile.schema.json validation failed (D12 fail-on-schema) \u2014 ${issues.length} violation(s):
1178
- ` + issues.map((i) => ` ${i.path || "<root>"}: ${i.message}`).join("\n"));
1179
- this.name = "SchemaValidationError";
1180
- this.issues = issues;
1181
- }
1182
- };
1183
- function validateProfile(value) {
1184
- const result = profileSchema.safeParse(value);
1185
- if (!result.success) {
1186
- const issues = result.error.issues.map((issue) => ({
1187
- path: issue.path.length ? issue.path.join(".") : "<root>",
1188
- message: issue.message
1189
- }));
1190
- throw new SchemaValidationError(issues);
1191
- }
1192
- return result.data;
1266
+ function langValues(langMap) {
1267
+ return langMap ? Object.values(langMap).filter((v) => v !== "") : [];
1193
1268
  }
1194
- function normalizeProfile(profile) {
1195
- return {
1196
- ...profile,
1197
- flavor: profile.flavor ?? "person",
1198
- bios: profile.bios ?? [],
1199
- headlines: profile.headlines ?? [],
1200
- skills: profile.skills ?? [],
1201
- experience: profile.experience ?? [],
1202
- projects: profile.projects ?? [],
1203
- publications: profile.publications ?? [],
1204
- checks: profile.checks ?? [],
1205
- socialHandles: profile.socialHandles ?? [],
1206
- press: profile.press ?? [],
1207
- education: profile.education ?? [],
1208
- languages: profile.languages ?? [],
1209
- deployments: profile.deployments ?? {},
1210
- credentials: profile.credentials ?? [],
1211
- artifacts: profile.artifacts ?? []
1212
- };
1269
+ function targetedPlatforms(profile) {
1270
+ const targets = /* @__PURE__ */ new Set();
1271
+ for (const b of profile.bios ?? [])
1272
+ targets.add(b.platform);
1273
+ for (const h of profile.headlines ?? [])
1274
+ targets.add(h.platform);
1275
+ for (const key of Object.keys(profile.active ?? {})) {
1276
+ if (PLATFORMS.includes(key)) {
1277
+ targets.add(key);
1278
+ }
1279
+ }
1280
+ for (const key of Object.keys(profile.keywords?.seo ?? {}))
1281
+ targets.add(key);
1282
+ return targets;
1213
1283
  }
1214
- function readContentFiles(workspace) {
1215
- const contentDir = join(workspace, "content");
1216
- if (!existsSync(contentDir)) {
1217
- throw new LoadError(`workspace has no content/ directory: ${contentDir} (expected content/*.yaml per schema/README.md)`);
1284
+ function semanticAnchors(profile) {
1285
+ const rawAnchors = (profile.positioning?.geo?.semanticAssociations ?? []).map((a) => a.trim()).filter(Boolean);
1286
+ const anchors = rawAnchors.map((a) => a.toLowerCase());
1287
+ if (anchors.length === 0)
1288
+ return { anchors: [], reinforced: [], missing: [], coverage: 0 };
1289
+ const haystack = [
1290
+ ...langValues(profile.positioning?.statement),
1291
+ ...langValues(profile.positioning?.geo?.citationsGoal),
1292
+ ...profile.keywords?.geo?.associations ?? [],
1293
+ ...profile.keywords?.geo?.claims ?? []
1294
+ ];
1295
+ for (const skill of profile.skills ?? []) {
1296
+ haystack.push(...skill.geoKeywords ?? [], ...skill.keywords ?? []);
1218
1297
  }
1219
- const files = readdirSync(contentDir).filter((file) => /\.ya?ml$/i.test(file) && !file.startsWith(".")).sort();
1220
- const merged = {};
1221
- for (const file of files) {
1222
- const rootKey = file.replace(/\.ya?ml$/i, "");
1223
- const fullPath = join(contentDir, file);
1224
- const doc = parseDocument(readFileSync(fullPath, "utf8"));
1225
- if (doc.errors.length > 0) {
1226
- throw new LoadError(`content/${file}: invalid YAML \u2014 ${doc.errors[0]?.message ?? "parse error"}`);
1227
- }
1228
- const node = doc.toJS({ maxAliasCount: 100 });
1229
- if (node === null || node === void 0)
1298
+ for (const project of profile.projects ?? []) {
1299
+ haystack.push(...project.keywords ?? [], ...langValues(project.description));
1300
+ }
1301
+ const normalized = haystack.map((text) => text.trim().toLowerCase());
1302
+ const reinforced = [];
1303
+ const missing = [];
1304
+ for (let i = 0; i < anchors.length; i++) {
1305
+ const anchor = anchors[i];
1306
+ if (!anchor)
1230
1307
  continue;
1231
- if (rootKey in merged) {
1232
- throw new LoadError(`content/${file}: duplicate root property "${rootKey}" (already provided by another content file) \u2014 each root property belongs to exactly one file`);
1233
- }
1234
- merged[rootKey] = node;
1308
+ if (normalized.some((text) => text.includes(anchor)))
1309
+ reinforced.push(rawAnchors[i] ?? anchor);
1310
+ else
1311
+ missing.push(rawAnchors[i] ?? anchor);
1235
1312
  }
1236
- return merged;
1313
+ return {
1314
+ anchors: rawAnchors,
1315
+ reinforced,
1316
+ missing,
1317
+ coverage: anchors.length === 0 ? 0 : reinforced.length / anchors.length
1318
+ };
1237
1319
  }
1238
- function load(workspace) {
1239
- const merged = readContentFiles(workspace);
1240
- const profile = validateProfile(merged);
1241
- const ledgerIssues = checkLedgerIntegrity(profile);
1242
- if (ledgerIssues.length > 0) {
1243
- throw new SchemaValidationError(ledgerIssues);
1320
+ function identityAuthorityDetail(profile) {
1321
+ const identity = profile.identity;
1322
+ const declared = linksWithRole(identity, "identity");
1323
+ if (declared.length > 0) {
1324
+ return "no @id resolved for this profile's declared role: identity link";
1244
1325
  }
1245
- const registryIssues = checkRegistryIntegrity(profile);
1246
- if (registryIssues.length > 0) {
1247
- throw new SchemaValidationError(registryIssues);
1326
+ const aggregators = linksWithRole(identity, "aggregator");
1327
+ if (aggregators.length > 0) {
1328
+ return `no role: identity link \u2014 ${aggregators.length} aggregator link(s) declared (${aggregators.join(", ")}) are NOT eligible for @id: an aggregator lists references to the entity, it does not describe it. Mark your own page (or profile) role: identity instead.`;
1248
1329
  }
1249
- return normalizeProfile(profile);
1330
+ const realizations = linksWithRole(identity, "realization");
1331
+ if (realizations.length > 0) {
1332
+ return `no role: identity link \u2014 all ${realizations.length} link(s) are role: realization (they indicate the entity, not are it). Mark the one that represents you role: identity to make the entity citable.`;
1333
+ }
1334
+ return "no links declared \u2014 add the page that represents this entity with role: identity to make it citable";
1335
+ }
1336
+ function geoCheck(profile) {
1337
+ const identity = profile.identity;
1338
+ const conflicts = driftCheck(profile).filter((issue) => CONFLICT_CODES.has(issue.code));
1339
+ const targets = targetedPlatforms(profile);
1340
+ const targetsWithBio = [...targets].filter((platform) => (profile.bios ?? []).some((b) => b.platform === platform)).length;
1341
+ const targetsWithHeadline = [...targets].filter((platform) => (profile.headlines ?? []).some((h) => h.platform === platform)).length;
1342
+ const targetCount = targets.size;
1343
+ const sd = structuredData(profile);
1344
+ const anchors = semanticAnchors(profile);
1345
+ const publications = profile.publications ?? [];
1346
+ const citablePublications = publications.filter((p) => p.doi || p.orcidId).length;
1347
+ const projects = profile.projects ?? [];
1348
+ const projectsWithEvidence = projects.filter((p) => (p.evidence ?? []).length > 0).length;
1349
+ const evidenceWithVerified = projects.flatMap((p) => p.evidence ?? []).filter((e) => Boolean(e.verified)).length;
1350
+ const totalEvidence = projects.flatMap((p) => p.evidence ?? []).length;
1351
+ const checks = [
1352
+ {
1353
+ code: "geo/conflicts",
1354
+ label: "No fact contradictions across surfaces",
1355
+ weight: 0.25,
1356
+ score: clamp01(1 - conflicts.length * 0.25),
1357
+ detail: conflicts.length === 0 ? "no fact contradictions detected" : `${conflicts.length} contradiction(s): ${[...new Set(conflicts.map((c) => c.code))].join(", ")}`
1358
+ },
1359
+ {
1360
+ code: "geo/platform-bio-coverage",
1361
+ label: "Every targeted platform has a bio",
1362
+ weight: 0.12,
1363
+ score: targetCount === 0 ? 1 : targetsWithBio / targetCount,
1364
+ detail: targetCount === 0 ? "no platforms targeted yet" : `${targetsWithBio}/${targetCount} platforms with a bio (${[...targets].filter((p) => !(profile.bios ?? []).some((b) => b.platform === p)).join(", ") || "all"} missing)`
1365
+ },
1366
+ {
1367
+ code: "geo/platform-headline-coverage",
1368
+ label: "Every targeted platform has a headline",
1369
+ weight: 0.08,
1370
+ score: targetCount === 0 ? 1 : targetsWithHeadline / targetCount,
1371
+ detail: targetCount === 0 ? "no platforms targeted yet" : `${targetsWithHeadline}/${targetCount} platforms with a headline`
1372
+ },
1373
+ {
1374
+ code: "geo/entity-id",
1375
+ label: "Canonical entity identifier (schema.org @id)",
1376
+ // Shares the entity-identity budget with `geo/structured-person` below: both score
1377
+ // the SAME thing (the entity's own structured data), so the pair totals the 0.10
1378
+ // that field-check alone used to carry. Weights sum to 1 by contract — the test
1379
+ // asserts it, and a new check must pay for itself out of an existing budget.
1380
+ weight: 0.05,
1381
+ // SPEC §15.4 / #103: the `@id` comes from the `role: identity` link and is never
1382
+ // invented. Absent = the profile declares no canonical realization, never silently
1383
+ // satisfied by a heuristic.
1384
+ //
1385
+ // HONEST ACCOUNTING (v1.6 review): this check is NOT score-neutral. Every workspace
1386
+ // that does not yet declare `role: identity` loses up to 5 points (weight 0.05) —
1387
+ // the largest single v1.6 delta. It is a *reported gap*, not a regression: the `@id`
1388
+ // was never emitted before, so nothing that worked stopped working. But it does mean
1389
+ // upgrading moves the GEO score, and a user comparing before/after deserves to know.
1390
+ score: sd.fields.id !== void 0 ? 1 : 0,
1391
+ detail: sd.fields.id !== void 0 ? `@id = ${sd.fields.id} (from the role: identity link)` : identityAuthorityDetail(profile)
1392
+ },
1393
+ {
1394
+ code: "geo/sameas-links",
1395
+ label: "Canonical identity links (schema.org sameAs)",
1396
+ weight: 0.15,
1397
+ // Counts `role: realization` links — the ones that actually become `sameAs`.
1398
+ // Counting identity/aggregator links here would credit a profile whose sameAs
1399
+ // set is empty, i.e. score a signal it does not emit.
1400
+ //
1401
+ // The rationale applies in BOTH directions (v1.6 review, F9): it counts only
1402
+ // `links[]`, while the emitted `sameAs` also carries claimed `socialHandles[].url`.
1403
+ // A profile can therefore reach 5/5 here with fewer than 5 emitted URLs (or score
1404
+ // under 5 with more) — this check measures the SSOT-declared realization set, not
1405
+ // the JSON-LD array. Kept as-is deliberately: handles are a brand-flavor construct
1406
+ // (§17.1) and counting them would make a person and a brand score differently for
1407
+ // the same citability. Documented rather than silently asymmetric.
1408
+ score: clamp01(linksWithRole(identity, "realization").length / 5),
1409
+ detail: `${linksWithRole(identity, "realization").length} link(s) with role: realization in identity.links (\u22653 recommended for GEO citability)`
1410
+ },
1411
+ {
1412
+ code: "geo/structured-person",
1413
+ label: "Entity fields beyond the minimum (photo/location/jobTitle \u2014 logo/description/foundingDate for brand)",
1414
+ // Paired with `geo/entity-id` above: the two together carry the entity-identity
1415
+ // budget that this check alone held before v1.6 (#103).
1416
+ weight: 0.05,
1417
+ // Read from sd.fields — structuredData is the single source of truth for
1418
+ // entity-field presence (issue #86, F1): Boolean(entity) on the raw object
1419
+ // credited `mission: {en: ""}` as present while structuredData (falsy en-read)
1420
+ // called it absent. Parity holds for every schema-valid populated value:
1421
+ // logo/established/photo are url/date-typed (non-empty), location is a plain
1422
+ // string where "" is falsy in both readings.
1423
+ score: profile.flavor === "brand" ? (Number(Boolean(sd.fields.logo)) + Number(Boolean(sd.fields.description)) + Number(Boolean(sd.fields.foundingDate))) / 3 : (Number(Boolean(sd.fields.image)) + Number(Boolean(sd.fields.address)) + Number(Boolean(sd.fields.jobTitle))) / 3,
1424
+ detail: profile.flavor === "brand" ? `logo: ${sd.fields.logo ? "yes" : "no"} \xB7 description(mission): ${sd.fields.description ? "yes" : "no"} \xB7 foundingDate: ${sd.fields.foundingDate ? "yes" : "no"}` : `photo: ${sd.fields.image ? "yes" : "no"} \xB7 location: ${sd.fields.address ? "yes" : "no"} \xB7 jobTitle: ${sd.fields.jobTitle ? "yes" : "no"}`
1425
+ },
1426
+ {
1427
+ code: "geo/semantic-anchors",
1428
+ label: "Semantic association anchors reinforced across surfaces",
1429
+ weight: 0.15,
1430
+ score: anchors.anchors.length === 0 ? 0 : anchors.reinforced.length / anchors.anchors.length,
1431
+ detail: anchors.anchors.length === 0 ? "no positioning.geo.semanticAssociations defined (3\u20135 anchors)" : `${anchors.reinforced.length}/${anchors.anchors.length} anchors reinforced; missing: ${anchors.missing.join(", ") || "none"}`
1432
+ },
1433
+ {
1434
+ code: "geo/citable-publications",
1435
+ label: "Publications resolvable via DOI/ORCID",
1436
+ weight: 0.05,
1437
+ score: publications.length === 0 ? 1 : citablePublications / publications.length,
1438
+ detail: publications.length === 0 ? "no publications" : `${citablePublications}/${publications.length} publications carry a DOI or ORCID`
1439
+ },
1440
+ {
1441
+ code: "geo/evidence-claims",
1442
+ label: "Projects carry verifiable evidence[]",
1443
+ weight: 0.1,
1444
+ score: projects.length === 0 ? 1 : projectsWithEvidence / projects.length,
1445
+ detail: projects.length === 0 ? "no projects" : `${projectsWithEvidence}/${projects.length} projects with evidence; ${evidenceWithVerified}/${totalEvidence} evidence claims have a verified date`
1446
+ }
1447
+ ];
1448
+ const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
1449
+ const score = Math.round(checks.reduce((sum, check) => sum + check.score * check.weight, 0) / totalWeight * 100);
1450
+ const section2 = (label, detail, subset) => {
1451
+ const weight = subset.reduce((sum, check) => sum + check.weight, 0);
1452
+ const sectionScore = Math.round(subset.reduce((sum, check) => sum + check.score * check.weight, 0) / weight * 100);
1453
+ return { label, detail, checks: subset, score: sectionScore };
1454
+ };
1455
+ const sections = {
1456
+ factConsistency: section2("Fact consistency", "An LLM triangulating platforms must find the same dates/roles/papers.", checks.filter((c) => c.code.startsWith("geo/conflicts") || c.code.startsWith("geo/platform-"))),
1457
+ structuredData: {
1458
+ ...section2(
1459
+ "Structured-data readiness",
1460
+ // Mirrors `ready = Boolean(name)` exactly (structured-data.ts). The old string still
1461
+ // claimed "need name + ≥1 sameAs link" — the predicate it no longer uses, i.e. the
1462
+ // same contradiction as the `ready` bug, one line away from its own fix.
1463
+ `schema.org ${sd.entityType} JSON-LD ${sd.ready ? "ready for the web renderer" : "not ready (need a name)"}.`,
1464
+ checks.filter((c) => c.code.startsWith("geo/sameas") || c.code.startsWith("geo/structured") || c.code === "geo/entity-id")
1465
+ ),
1466
+ readiness: sd
1467
+ },
1468
+ semanticAssociations: {
1469
+ ...section2("Semantic associations", "The 3\u20135 anchors an LLM should associate with the person, reinforced across surfaces.", checks.filter((c) => c.code === "geo/semantic-anchors")),
1470
+ anchors
1471
+ },
1472
+ citability: section2("Citability", "Verifiable claims: resolvable DOIs/ORCIDs and evidence[] with sources.", checks.filter((c) => c.code.startsWith("geo/citable") || c.code.startsWith("geo/evidence")))
1473
+ };
1474
+ return { score, sections, checks };
1250
1475
  }
1251
1476
 
1252
1477
  // ../core/dist/media.js
@@ -1710,6 +1935,9 @@ function render3(p, opts = {}) {
1710
1935
  if (p.structuredData?.jsonLd !== void 0) {
1711
1936
  out.push("", "<!-- JSON-LD (schema.org Person) -->", "```json", JSON.stringify(p.structuredData.jsonLd, null, 2), "```");
1712
1937
  }
1938
+ if (p.completeness !== void 0) {
1939
+ out.push("", `Profile completeness: ${completenessLine(p.completeness)}`);
1940
+ }
1713
1941
  out.push("", `<!-- generated by profile-as-code pasco build github-readme \xB7 ${plural2(projects.length, "project")} \xB7 ${plural2(p.publications.length, "publication")} -->`);
1714
1942
  return {
1715
1943
  text: `${out.join("\n")}
@@ -1802,6 +2030,18 @@ function line(text) {
1802
2030
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1803
2031
  import { dirname, join as join3, resolve as resolve2 } from "path";
1804
2032
  var WORKSPACE_NAME = "profile-data";
2033
+ function sanitizeCompletenessPolicy(raw) {
2034
+ if (raw === void 0) return {};
2035
+ const items = (raw.items ?? []).filter(
2036
+ (i) => typeof i?.id === "string" && i.id !== "" && Number.isFinite(i.weight)
2037
+ );
2038
+ const langs = (raw.langs ?? []).filter((l) => typeof l === "string" && l !== "");
2039
+ return {
2040
+ ...typeof raw.target === "string" ? { target: raw.target } : {},
2041
+ ...langs.length > 0 ? { langs } : {},
2042
+ ...items.length > 0 ? { items } : {}
2043
+ };
2044
+ }
1805
2045
  var ConfigError = class extends Error {
1806
2046
  constructor(message) {
1807
2047
  super(message);
@@ -1875,7 +2115,10 @@ function runBuild(args) {
1875
2115
  }
1876
2116
  const platforms = args.platformArg !== void 0 ? [parsePlatform(args.platformArg)] : [...PLATFORMS];
1877
2117
  const lang = parseLang(defaultsLang(args.pascorc, args.langArg));
1878
- const slices = platforms.map((p) => compile(profile, p, lang));
2118
+ const completenessPolicy = sanitizeCompletenessPolicy(args.pascorc.completeness);
2119
+ const slices = platforms.map(
2120
+ (p) => compile(profile, p, lang, completenessPolicy)
2121
+ );
1879
2122
  const bundle = collectMedia(profile, args.workspace);
1880
2123
  const written = [];
1881
2124
  const skipped = [];
@@ -2667,12 +2910,19 @@ function markDeployed(args, profile) {
2667
2910
  );
2668
2911
  return EXIT.OK;
2669
2912
  }
2670
- function checklistBody(reportData) {
2913
+ function checklistBody(reportData, completion) {
2671
2914
  const fmt = (surfaces) => Object.entries(surfaces).sort().map(([s, state]) => `${s} (${state})`).join(", ");
2672
2915
  const stale = reportData.units.filter((u) => u.state === "stale");
2673
2916
  const inSync = reportData.units.filter((u) => u.state === "in-sync");
2674
2917
  const never = reportData.units.filter((u) => u.state === "never");
2675
- const lines2 = [CHECKLIST_HEADER, ""];
2918
+ const lines2 = [
2919
+ CHECKLIST_HEADER,
2920
+ "",
2921
+ // Issue #102: the completeness number rides the SAME generated view — it is
2922
+ // derived state (never stored) and the checklist is where the user looks.
2923
+ `Profile completeness: ${completenessLine(completion)}`,
2924
+ ""
2925
+ ];
2676
2926
  lines2.push("## Pending \u2014 stale (SSOT changed after the last deploy)");
2677
2927
  if (stale.length === 0) lines2.push("_None._");
2678
2928
  for (const u of stale) lines2.push(`- [ ] \`${u.key}\` (${u.label}) \u2014 ${fmt(u.surfaces)}`);
@@ -2693,6 +2943,31 @@ function checklistBody(reportData) {
2693
2943
  lines2.push("");
2694
2944
  return lines2.join("\n");
2695
2945
  }
2946
+ function completenessOf(args, profile) {
2947
+ return completeness(profile, sanitizeCompletenessPolicy(args.completenessPolicy));
2948
+ }
2949
+ function runCompleteness(args, profile) {
2950
+ const result = completenessOf(args, profile);
2951
+ if (isJsonMode()) {
2952
+ return report("status", true, EXIT.OK, {
2953
+ data: {
2954
+ mode: "completeness",
2955
+ workspace: args.workspace,
2956
+ percent: result.percent,
2957
+ satisfied: result.satisfied,
2958
+ total: result.total,
2959
+ target: result.target,
2960
+ items: result.items
2961
+ }
2962
+ });
2963
+ }
2964
+ section(`profile completeness \u2014 ${completenessLine(result)} (target: ${result.target})`);
2965
+ for (const item of result.items) {
2966
+ line(` ${item.satisfied ? "[x]" : "[ ]"} ${item.label} \u2014 ${item.detail}`);
2967
+ }
2968
+ line(" \u2192 pasco status --write-checklist folds the number into CHECKLIST.md");
2969
+ return EXIT.OK;
2970
+ }
2696
2971
  function runStatus(args) {
2697
2972
  if (args.markKey !== void 0 && args.writeChecklist === true) {
2698
2973
  return report("status", false, EXIT.USAGE, {
@@ -2702,19 +2977,51 @@ function runStatus(args) {
2702
2977
  }
2703
2978
  });
2704
2979
  }
2980
+ if (args.completeness === true && args.markKey !== void 0) {
2981
+ return report("status", false, EXIT.USAGE, {
2982
+ error: {
2983
+ kind: "usage",
2984
+ message: "usage: --completeness and --mark are mutually exclusive \u2014 run them sequentially"
2985
+ }
2986
+ });
2987
+ }
2988
+ if (args.completeness === true && args.writeChecklist === true) {
2989
+ return report("status", false, EXIT.USAGE, {
2990
+ error: {
2991
+ kind: "usage",
2992
+ message: "usage: --completeness is a read-only report and --write-checklist writes CHECKLIST.md \u2014 run them separately (the generated view includes the completeness line)"
2993
+ }
2994
+ });
2995
+ }
2705
2996
  const loaded = loadOrReport(args);
2706
2997
  if ("fail" in loaded) return loaded.fail;
2707
2998
  const profile = loaded.profile;
2708
2999
  if (args.markKey !== void 0) return markDeployed(args, profile);
2709
3000
  const reportData = deployStatus(profile);
3001
+ const completion = completenessOf(args, profile);
3002
+ if (args.completeness === true) return runCompleteness(args, profile);
2710
3003
  if (args.writeChecklist === true) {
2711
- writeFileSync4(join7(args.workspace, "CHECKLIST.md"), checklistBody(reportData), "utf8");
3004
+ writeFileSync4(
3005
+ join7(args.workspace, "CHECKLIST.md"),
3006
+ checklistBody(reportData, completion),
3007
+ "utf8"
3008
+ );
2712
3009
  if (isJsonMode()) {
2713
3010
  return report("status", true, EXIT.OK, {
2714
- data: { mode: "checklist-written", path: "CHECKLIST.md", ...summaryOf(reportData) }
3011
+ data: {
3012
+ mode: "checklist-written",
3013
+ path: "CHECKLIST.md",
3014
+ ...summaryOf(reportData),
3015
+ completeness: {
3016
+ percent: completion.percent,
3017
+ satisfied: completion.satisfied,
3018
+ total: completion.total
3019
+ }
3020
+ }
2715
3021
  });
2716
3022
  }
2717
3023
  line("CHECKLIST.md regenerated from content/deployments.yaml (AUTO-GENERATED view)");
3024
+ line(` Profile completeness: ${completenessLine(completion)}`);
2718
3025
  }
2719
3026
  const filtered = args.platformArg !== void 0 ? {
2720
3027
  ...reportData,
@@ -2912,15 +3219,18 @@ owner line (src/commands/check.ts).`,
2912
3219
  "skills:install": `pasco skills:install <agent>
2913
3220
  Prints the skills-studio projection command for <agent> (pi|claude|aionui|opencode).
2914
3221
  Projection is the skills-studio job (AGOS), not the CLI's.`,
2915
- status: `pasco status [platform] [--mark <ledgerKey> <surface>] [--at <iso8601>] [--write-checklist] [--json] [--workspace <path>]
3222
+ status: `pasco status [platform] [--mark <ledgerKey> <surface>] [--at <iso8601>] [--write-checklist] [--completeness] [--json] [--workspace <path>]
2916
3223
  Per-entry deploy-status report (D18, \xA719): ledger key \xB7 origin (template/user) \xB7
2917
3224
  surfaces published \xB7 sync state (in-sync / stale / never) \u2014 plus untracked rows
2918
3225
  (add an id to track) and removed keys (ledger keeps the audit trail).
2919
3226
  --mark <ledgerKey> <surface> confirm a manual paste: records deployedAt + the
2920
3227
  CURRENT contentHash in content/deployments.yaml (only pasco writes it,
2921
3228
  D14). --at overrides the timestamp.
2922
- --write-checklist regenerate CHECKLIST.md from the ledger
2923
- (AUTO-GENERATED view \u2014 do not edit by hand).`
3229
+ --write-checklist regenerate CHECKLIST.md from the ledger + the
3230
+ completeness line (AUTO-GENERATED view \u2014 do not edit by hand).
3231
+ --completeness profile-completeness checklist + percentage
3232
+ (issue #102). Policy (target/weights/langs) from .pascorc "completeness";
3233
+ the percentage is DERIVED \u2014 only the policy is ever stored.`
2924
3234
  };
2925
3235
  function runMain() {
2926
3236
  const argv = process.argv.slice(2);
@@ -3050,7 +3360,8 @@ function runMain() {
3050
3360
  const { flags, positionals } = parseCommandFlags(rest, {
3051
3361
  mark: { type: "string" },
3052
3362
  at: { type: "string" },
3053
- "write-checklist": { type: "boolean" }
3363
+ "write-checklist": { type: "boolean" },
3364
+ completeness: { type: "boolean" }
3054
3365
  });
3055
3366
  if (flags.help === true) {
3056
3367
  process.stdout.write(`${COMMAND_HELP.status}
@@ -3061,7 +3372,9 @@ function runMain() {
3061
3372
  throw new UsageError("usage: pasco status --mark <ledgerKey> <surface> [--at <iso8601>]");
3062
3373
  }
3063
3374
  if (flags.mark === void 0 && positionals.length > 1) {
3064
- throw new UsageError("usage: pasco status [platform] [--write-checklist] [--json]");
3375
+ throw new UsageError(
3376
+ "usage: pasco status [platform] [--write-checklist | --completeness] [--json]"
3377
+ );
3065
3378
  }
3066
3379
  setJsonMode(flags.json === true);
3067
3380
  const workspace = resolveWorkspace(process.cwd(), flags.workspace);
@@ -3070,7 +3383,9 @@ function runMain() {
3070
3383
  platformArg: positionals[0],
3071
3384
  markKey: flags.mark,
3072
3385
  markAt: flags.at,
3073
- writeChecklist: flags["write-checklist"]
3386
+ writeChecklist: flags["write-checklist"],
3387
+ completeness: flags.completeness,
3388
+ completenessPolicy: readWorkspacePascorc(workspace).completeness
3074
3389
  });
3075
3390
  }
3076
3391
  case "skills:install": {