@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.
- package/README.md +52 -8
- package/dist/index.js +1286 -971
- 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/
|
|
12
|
-
|
|
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/
|
|
332
|
-
var
|
|
333
|
-
function
|
|
334
|
-
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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/
|
|
450
|
-
var
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
424
|
+
push("positioning.geo.citationsGoal", profile.positioning?.geo?.citationsGoal);
|
|
478
425
|
for (const [name, map] of Object.entries(profile.audiences ?? {})) {
|
|
479
|
-
|
|
480
|
-
if (text !== void 0)
|
|
481
|
-
brandAudiences[name] = text;
|
|
426
|
+
push(`audiences.${name}`, map);
|
|
482
427
|
}
|
|
483
|
-
const
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
541
|
-
return
|
|
590
|
+
function completenessLine(report2) {
|
|
591
|
+
return `${report2.percent}% \u2014 ${report2.satisfied}/${report2.total}`;
|
|
542
592
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
552
|
-
|
|
618
|
+
|
|
619
|
+
// ../core/dist/registry.js
|
|
620
|
+
function isPublicEntry(entry) {
|
|
621
|
+
return (entry.visibility ?? "public") === "public";
|
|
553
622
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
if (match?.[1])
|
|
562
|
-
return match[1];
|
|
641
|
+
map.set(entry.id, index);
|
|
563
642
|
}
|
|
564
|
-
return
|
|
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
|
|
645
|
+
function duplicateIssues(block, entries) {
|
|
577
646
|
const issues = [];
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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 (
|
|
619
|
-
|
|
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
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
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
|
|
764
|
+
return findings;
|
|
715
765
|
}
|
|
716
766
|
|
|
717
|
-
// ../core/dist/
|
|
718
|
-
var
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
"
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
740
|
-
return
|
|
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
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
754
|
-
targets.add(key);
|
|
755
|
-
return targets;
|
|
836
|
+
return merged;
|
|
756
837
|
}
|
|
757
|
-
function
|
|
758
|
-
const
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
772
|
-
|
|
845
|
+
const registryIssues = checkRegistryIntegrity(profile);
|
|
846
|
+
if (registryIssues.length > 0) {
|
|
847
|
+
throw new SchemaValidationError(registryIssues);
|
|
773
848
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
962
|
+
entityType: "Person",
|
|
963
|
+
ready,
|
|
964
|
+
completeness: populated / considered.length,
|
|
965
|
+
fields,
|
|
966
|
+
...jsonLd ? { jsonLd } : {}
|
|
791
967
|
};
|
|
792
968
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
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/
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
function
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
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
|
|
1013
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
|
1024
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const
|
|
1076
|
-
const
|
|
1077
|
-
|
|
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
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
|
1108
|
-
for (const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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/
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
|
|
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
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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 (
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
|
1313
|
+
return {
|
|
1314
|
+
anchors: rawAnchors,
|
|
1315
|
+
reinforced,
|
|
1316
|
+
missing,
|
|
1317
|
+
coverage: anchors.length === 0 ? 0 : reinforced.length / anchors.length
|
|
1318
|
+
};
|
|
1237
1319
|
}
|
|
1238
|
-
function
|
|
1239
|
-
const
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
|
1246
|
-
if (
|
|
1247
|
-
|
|
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
|
-
|
|
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
|
|
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 = [
|
|
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(
|
|
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: {
|
|
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(
|
|
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": {
|