website-build-skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/AGENTS.md +19 -0
  4. package/CHANGELOG.md +42 -0
  5. package/CLAUDE.md +16 -0
  6. package/CODE_OF_CONDUCT.md +127 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/LICENSE +21 -0
  9. package/MAINTAINERS.md +12 -0
  10. package/README.md +148 -0
  11. package/RELEASING.md +133 -0
  12. package/REPO_SETTINGS.md +91 -0
  13. package/SECURITY.md +28 -0
  14. package/adapters/agents-md.md +26 -0
  15. package/adapters/antigravity.md +68 -0
  16. package/adapters/chatgpt-project.md +57 -0
  17. package/adapters/claude-code.md +25 -0
  18. package/adapters/claude-desktop.md +33 -0
  19. package/adapters/codex.md +21 -0
  20. package/adapters/custom-gpt.md +57 -0
  21. package/adapters/grok.md +100 -0
  22. package/adapters/hermes.md +49 -0
  23. package/adapters/team.yaml +42 -0
  24. package/adapters/zero-install.md +17 -0
  25. package/bin/website-build-skill.mjs +6 -0
  26. package/docs/COMPATIBILITY.md +34 -0
  27. package/docs/EVALUATION.md +109 -0
  28. package/docs/INSTALLER.md +73 -0
  29. package/docs/LIBRARY-CHECK.md +95 -0
  30. package/docs/METHOD.md +32 -0
  31. package/docs/PROVENANCE.md +36 -0
  32. package/docs/TIPS.md +18 -0
  33. package/docs/bundles/checklists.md +414 -0
  34. package/docs/bundles/method.md +1398 -0
  35. package/docs/bundles/playbooks.md +1566 -0
  36. package/docs/bundles/prompts.md +987 -0
  37. package/docs/bundles/team.md +1781 -0
  38. package/docs/evaluation/host-trial.md +95 -0
  39. package/docs/evaluation/receipt-template.json +18 -0
  40. package/docs/evidence/audit-baseline.json +61 -0
  41. package/docs/evidence/content-review.json +141 -0
  42. package/docs/evidence/floor.json +345 -0
  43. package/docs/evidence/library-check.json +164 -0
  44. package/docs/evidence/local-checks.json +156 -0
  45. package/llms.txt +74 -0
  46. package/package.json +68 -0
  47. package/skills/website-build-skill/SKILL.md +124 -0
  48. package/skills/website-build-skill/archive/graphics-design-original.md +70 -0
  49. package/skills/website-build-skill/checklists/accessibility.md +38 -0
  50. package/skills/website-build-skill/checklists/brand-and-mockups.md +43 -0
  51. package/skills/website-build-skill/checklists/build.md +39 -0
  52. package/skills/website-build-skill/checklists/performance-seo.md +40 -0
  53. package/skills/website-build-skill/checklists/research.md +91 -0
  54. package/skills/website-build-skill/checklists/security.md +38 -0
  55. package/skills/website-build-skill/checklists/ship.md +59 -0
  56. package/skills/website-build-skill/examples/audit-cases.md +66 -0
  57. package/skills/website-build-skill/examples/fictional-studio-brief.md +73 -0
  58. package/skills/website-build-skill/examples/fictional-studio-handoff.md +98 -0
  59. package/skills/website-build-skill/manifest.json +1474 -0
  60. package/skills/website-build-skill/playbooks/accessibility.md +91 -0
  61. package/skills/website-build-skill/playbooks/choose-stack.md +69 -0
  62. package/skills/website-build-skill/playbooks/data-and-templates.md +89 -0
  63. package/skills/website-build-skill/playbooks/deploy-and-operate.md +87 -0
  64. package/skills/website-build-skill/playbooks/embed-facades.md +59 -0
  65. package/skills/website-build-skill/playbooks/image-pipeline.md +73 -0
  66. package/skills/website-build-skill/playbooks/performance-budgets.md +76 -0
  67. package/skills/website-build-skill/playbooks/research-and-memory.md +398 -0
  68. package/skills/website-build-skill/playbooks/security-headers.md +132 -0
  69. package/skills/website-build-skill/playbooks/self-hosted-fonts.md +62 -0
  70. package/skills/website-build-skill/playbooks/structured-data-and-llms.md +84 -0
  71. package/skills/website-build-skill/prompts/00-start.md +94 -0
  72. package/skills/website-build-skill/prompts/01-website-deep-research.md +233 -0
  73. package/skills/website-build-skill/prompts/03-extract-brand-kit.md +46 -0
  74. package/skills/website-build-skill/prompts/04-three-mockups.md +44 -0
  75. package/skills/website-build-skill/prompts/05-stack-and-build.md +46 -0
  76. package/skills/website-build-skill/prompts/06-site-audit.md +39 -0
  77. package/skills/website-build-skill/prompts/07-accessibility-contrast.md +38 -0
  78. package/skills/website-build-skill/prompts/08-seo-aeo.md +72 -0
  79. package/skills/website-build-skill/prompts/09-security-headers.md +40 -0
  80. package/skills/website-build-skill/prompts/10-adversarial-pre-ship.md +44 -0
  81. package/skills/website-build-skill/prompts/11-ship-and-verify.md +44 -0
  82. package/skills/website-build-skill/prompts/12-web-design-team.md +141 -0
  83. package/skills/website-build-skill/references/stacks/framework.md +76 -0
  84. package/skills/website-build-skill/references/stacks/scripted-static.md +76 -0
  85. package/skills/website-build-skill/references/stacks/static-html.md +72 -0
  86. package/skills/website-build-skill/roles/builder.md +139 -0
  87. package/skills/website-build-skill/roles/coordinator.md +132 -0
  88. package/skills/website-build-skill/roles/designer.md +125 -0
  89. package/skills/website-build-skill/roles/graphics.md +126 -0
  90. package/skills/website-build-skill/roles/optimizer.md +129 -0
  91. package/skills/website-build-skill/roles/researcher.md +202 -0
  92. package/skills/website-build-skill/roles/reviewer.md +139 -0
  93. package/skills/website-build-skill/templates/audit-brief.md +63 -0
  94. package/skills/website-build-skill/templates/brand.md +53 -0
  95. package/skills/website-build-skill/templates/brief.md +51 -0
  96. package/skills/website-build-skill/templates/evidence.md +100 -0
  97. package/skills/website-build-skill/templates/handoff.yaml +23 -0
  98. package/skills/website-build-skill/templates/install-choice.txt +28 -0
  99. package/skills/website-build-skill/templates/site-operations.md +71 -0
  100. package/skills/website-build-skill/templates/status.md +47 -0
  101. package/skills/website-build-skill/templates/tokens.json +72 -0
  102. package/src/agents.mjs +7 -0
  103. package/src/bundle.mjs +106 -0
  104. package/src/catalog.mjs +12 -0
  105. package/src/choice.mjs +6 -0
  106. package/src/cli.mjs +96 -0
  107. package/src/install.mjs +344 -0
  108. package/src/library-check.mjs +106 -0
  109. package/src/validate.mjs +351 -0
@@ -0,0 +1,344 @@
1
+ import fs from 'node:fs';
2
+ import { resolve, relative, dirname, basename, sep } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CORE, files, owned, digest } from './bundle.mjs';
6
+ import { targets } from './catalog.mjs';
7
+
8
+ const packageRoot = fileURLToPath(new URL('../', import.meta.url));
9
+ export const packageInfo = (() => {
10
+ const { name, version } = JSON.parse(fs.readFileSync(owned(packageRoot, 'package.json'), 'utf8'));
11
+ return { name, version };
12
+ })();
13
+ export const choiceText = () => fs.readFileSync(owned(packageRoot, `${CORE}/templates/install-choice.txt`), 'utf8');
14
+ const stat = path => {
15
+ try { return fs.lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
16
+ };
17
+ const inside = (root, path) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
18
+
19
+ // A path the user typed is their own environment, so its own symlinks are resolved rather
20
+ // than refused: /tmp is a symlink on macOS, and a home or project can sit on a linked volume.
21
+ // Returns the real path of the nearest existing ancestor with the missing tail re-appended.
22
+ export function canonical(path) {
23
+ let current = resolve(path);
24
+ const tail = [];
25
+ for (;;) {
26
+ const entry = stat(current);
27
+ if (entry) {
28
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) throw Error(`Not a directory: ${current}`);
29
+ // A dangling link has no real path; say so instead of surfacing a bare ENOENT.
30
+ try { return resolve(fs.realpathSync(current), ...tail); }
31
+ catch { throw Error(`Cannot resolve: ${current}`); }
32
+ }
33
+ const parent = dirname(current);
34
+ if (parent === current) throw Error(`No existing ancestor: ${path}`);
35
+ tail.unshift(basename(current));
36
+ current = parent;
37
+ }
38
+ }
39
+
40
+ // Below a canonical, user-chosen root, a symlink is something a third party planted in the
41
+ // install target, so it is refused. lstat observes a dangling link, unlike existsSync.
42
+ // Every caller passes the root the path belongs to. Re-deriving a root from the path's own
43
+ // parent would resolve whatever had just been swapped in and lose the boundary entirely.
44
+ export function safePath(root, name) {
45
+ const base = resolve(root), path = resolve(base, name);
46
+ if (!inside(base, path)) throw Error(`Path escapes destination root: ${path}`);
47
+ // The base was canonical when it was resolved; a link here means it was replaced since.
48
+ if (stat(base)?.isSymbolicLink()) throw Error(`Symlink refused: ${base}`);
49
+ let current = base;
50
+ for (const segment of relative(base, path).split(sep).filter(Boolean)) {
51
+ current = resolve(current, segment);
52
+ const entry = stat(current);
53
+ if (entry?.isSymbolicLink()) throw Error(`Symlink refused: ${current}`);
54
+ if (entry && current !== path && !entry.isDirectory()) throw Error(`Not a directory: ${current}`);
55
+ }
56
+ return path;
57
+ }
58
+
59
+ const receiptName = '.website-build-skill-receipt.json';
60
+ const result = (code, message) => ({ code, message });
61
+
62
+ function validate(request) {
63
+ if (!Object.hasOwn(targets, request.target)) throw Error('Unknown or missing target');
64
+ if (!['solo', 'team'].includes(request.mode)) throw Error('Mode must be solo or team');
65
+ if (!['project', 'user'].includes(request.scope ?? 'project')) throw Error('Scope must be project or user');
66
+ if (request.bundleDir !== undefined && (request.mode !== 'team' || request.target !== 'hermes')) throw Error('--bundle-dir requires --team --target hermes');
67
+ if (request.outputStorage !== undefined) {
68
+ if (!request.outputDir || !['obsidian', 'folder', 'notion', 'other'].includes(request.outputStorage?.kind)) throw Error('Invalid output storage');
69
+ if (request.outputStorage.kind === 'notion' && (typeof request.outputStorage.exportTarget !== 'string' || !request.outputStorage.exportTarget.trim())) throw Error('Notion export destination is required');
70
+ }
71
+ }
72
+
73
+ function plan(request, destination) {
74
+ const coreRoot = owned(packageRoot, CORE);
75
+ const entries = files(coreRoot).map(name => ({ path: safePath(destination, name), bytes: fs.readFileSync(owned(coreRoot, name)) }));
76
+ if (request.mode === 'team') {
77
+ for (const name of files(packageRoot, 'docs/bundles').filter(name => /^docs\/bundles\/[^/]+\.md$/.test(name))) {
78
+ const bytes = fs.readFileSync(owned(packageRoot, name));
79
+ entries.push({ path: safePath(destination, `bundles/${basename(name)}`), bytes });
80
+ if (request.bundleDir !== undefined) {
81
+ const path = safePath(destination, resolve(destination, request.bundleDir, basename(name)));
82
+ if (!entries.some(entry => entry.path === path)) entries.push({ path, bytes });
83
+ }
84
+ }
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ function integrationFor(project, destination, target) {
90
+ const conventional = { 'claude-code': 'CLAUDE.md', codex: 'AGENTS.md', hermes: 'AGENTS.md', grok: 'GROK.md', antigravity: 'GEMINI.md' };
91
+ const routers = [...new Set(['AGENTS.md', 'CLAUDE.md', conventional[target]].filter(Boolean))];
92
+ const routerPath = routers.map(name => resolve(project, name)).find(path => stat(path)) ?? null;
93
+ return {
94
+ routerPath, merged: false,
95
+ snippet: routerPath ? `<!-- website-build-skill integration -->\nRead ${resolve(destination, 'SKILL.md')} and begin with prompts/00-start.md. Activation: UNVERIFIED.` : null,
96
+ };
97
+ }
98
+
99
+ function readReceipt(path, destination, receiptRoot) {
100
+ safePath(receiptRoot, path);
101
+ const info = stat(path);
102
+ if (!info?.isFile() || info.nlink !== 1) throw Error(`Missing, shared or non-file receipt: ${path}`);
103
+ const value = JSON.parse(fs.readFileSync(path, 'utf8'));
104
+ // The destination is always derived from what the user named, never read out of the file,
105
+ // so this comparison is a check that the receipt belongs here, not a source of authority.
106
+ if (value.schemaVersion !== 1 || value.package?.name !== packageInfo.name || value.destination !== destination || !Array.isArray(value.files) || !Array.isArray(value.directories)) throw Error('Invalid receipt ownership');
107
+ if (value.activation !== 'UNVERIFIED' || !['solo', 'team'].includes(value.mode) || !Object.hasOwn(targets, value.target) || !['project', 'user'].includes(value.scope)) throw Error('Invalid receipt metadata');
108
+ const seen = new Set();
109
+ for (const entry of value.files) {
110
+ if (typeof entry.path !== 'string' || !entry.path || entry.path !== resolve(entry.path) || entry.path === destination || entry.path === path || !inside(destination, entry.path) || seen.has(entry.path) || !/^[a-f0-9]{64}$/.test(entry.sha256) || !['written', 'identical'].includes(entry.action)) throw Error('Invalid receipt file');
111
+ safePath(destination, entry.path);
112
+ seen.add(entry.path);
113
+ }
114
+ for (const directory of value.directories) {
115
+ if (typeof directory !== 'string' || directory !== resolve(directory) || directory === destination || !inside(destination, directory)) throw Error('Invalid receipt directory');
116
+ safePath(destination, directory);
117
+ }
118
+ return value;
119
+ }
120
+
121
+ function mkdirFor(path, root, made) {
122
+ safePath(root, path);
123
+ const target = dirname(path);
124
+ // Record every directory inside the root that does not exist yet, rather than trusting
125
+ // mkdirSync's return: it names only the TOPMOST directory it creates, which for the first
126
+ // file of a fresh install is above the destination, leaving its subdirectories unrecorded
127
+ // and therefore never removed. A directory that already exists is never ours.
128
+ const missing = [];
129
+ for (let current = target; inside(root, current) && current !== root && !stat(current); current = dirname(current)) missing.unshift(current);
130
+ fs.mkdirSync(target, { recursive: true });
131
+ made.push(...missing);
132
+ safePath(root, path);
133
+ }
134
+ function writeNew(path, bytes, landed, root, made) {
135
+ mkdirFor(path, root, made);
136
+ const fd = fs.openSync(path, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o644);
137
+ landed.push(path);
138
+ try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
139
+ }
140
+ function writeReceipt(path, value, previous, state, root, made) {
141
+ mkdirFor(path, root, made);
142
+ if (previous !== null) {
143
+ if (!stat(path)?.isFile() || fs.readFileSync(path, 'utf8') !== previous) throw Error(`Receipt changed during install: ${path}`);
144
+ const fd = fs.openSync(path, fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0));
145
+ state.started = true;
146
+ try { fs.ftruncateSync(fd); fs.writeFileSync(fd, JSON.stringify(value, null, 2) + '\n'); } finally { fs.closeSync(fd); }
147
+ } else {
148
+ const created = [];
149
+ try { writeNew(path, JSON.stringify(value, null, 2) + '\n', created, root, made); }
150
+ finally { state.started = created.includes(path); }
151
+ }
152
+ }
153
+ function probe(root) {
154
+ if (!stat(root)?.isDirectory()) throw Error(`Output root must be an existing writable directory: ${root}`);
155
+ const path = safePath(root, `.website-build-skill-probe-${randomUUID()}`);
156
+ let created = false;
157
+ try {
158
+ const fd = fs.openSync(path, 'wx', 0o600);
159
+ created = true;
160
+ try { fs.writeFileSync(fd, 'website-build-skill'); } finally { fs.closeSync(fd); }
161
+ if (fs.readFileSync(path, 'utf8') !== 'website-build-skill') throw Error('Output root probe read-back failed');
162
+ } finally { if (created) fs.unlinkSync(path); }
163
+ }
164
+
165
+ // Remove empty directories inside one tree this install created, deepest first. A directory
166
+ // that existed before the install is never in that list, so it is never removed.
167
+ function pruneCreated(destination, root, removed) {
168
+ const walk = path => {
169
+ safePath(destination, path);
170
+ const info = stat(path);
171
+ if (!info?.isDirectory()) return;
172
+ for (const name of fs.readdirSync(path)) walk(resolve(path, name));
173
+ if (fs.readdirSync(path).length === 0 && path !== destination) { fs.rmdirSync(path); removed.push(path); }
174
+ };
175
+ walk(root);
176
+ }
177
+
178
+ function uninstall(request, destination, receiptPath, receiptRoot) {
179
+ let receipt;
180
+ try { receipt = readReceipt(receiptPath, destination, receiptRoot); } catch (error) { return result(2, error.message); }
181
+ const remove = [], kept = [];
182
+ try {
183
+ for (const entry of receipt.files) {
184
+ const info = stat(entry.path);
185
+ if (!info) continue;
186
+ // A file recorded as identical was already on disk before this package touched it.
187
+ // Installing it was a no-op, so removing it would be taking something that is not ours.
188
+ if (entry.action === 'identical') { kept.push(`Kept preexisting: ${entry.path}`); continue; }
189
+ if (info.isFile() && digest(fs.readFileSync(entry.path)) === entry.sha256) remove.push(entry);
190
+ else kept.push(`Kept changed: ${entry.path}`);
191
+ }
192
+ const known = new Set([...receipt.files.map(entry => entry.path), receiptPath]);
193
+ const visit = path => {
194
+ const info = stat(path);
195
+ if (info?.isDirectory()) for (const name of fs.readdirSync(path)) visit(resolve(path, name));
196
+ else if (info && !known.has(path)) kept.push(`Kept unlisted: ${path}`);
197
+ };
198
+ visit(destination);
199
+ if (request.dryRun) return result(0, [...remove.map(entry => `Would remove: ${entry.path}`), ...kept, `Would remove receipt: ${receiptPath}`].join('\n'));
200
+ const removed = [];
201
+ try {
202
+ for (const entry of remove) {
203
+ safePath(destination, entry.path);
204
+ if (digest(fs.readFileSync(entry.path)) !== entry.sha256) { kept.push(`Kept changed: ${entry.path}`); continue; }
205
+ fs.unlinkSync(entry.path);
206
+ removed.push(entry.path);
207
+ }
208
+ safePath(receiptRoot, receiptPath);
209
+ fs.unlinkSync(receiptPath);
210
+ for (const directory of [...receipt.directories].sort((a, b) => b.length - a.length)) pruneCreated(destination, directory, removed);
211
+ return result(0, [...removed.map(path => `Removed: ${path}`), ...kept, 'Uninstall complete.'].join('\n'));
212
+ } catch (error) { return result(4, `Uninstall interrupted: ${error.message}\n${removed.map(path => `Removed: ${path}`).join('\n')}\n${kept.join('\n')}`); }
213
+ } catch (error) { return result(3, error.message); }
214
+ }
215
+
216
+ export function install(request = {}) {
217
+ let destination, receiptPath, receiptRoot, entries, integration, previous = null, oldReceipt, outputRoot, project;
218
+ try {
219
+ if (request.uninstall) {
220
+ // What gets removed is decided by what the user named. A receipt that carries its own
221
+ // destination would let an edited or planted file point the removal anywhere on disk.
222
+ if (request.target && request.dir) {
223
+ if (!Object.hasOwn(targets, request.target)) throw Error('Unknown or missing target');
224
+ destination = resolve(canonical(request.dir), targets[request.target]);
225
+ receiptPath = resolve(request.receipt ?? resolve(destination, receiptName));
226
+ } else if (request.receipt) {
227
+ receiptPath = resolve(request.receipt);
228
+ destination = canonical(dirname(receiptPath));
229
+ } else throw Error('Uninstall requires --receipt or --target and --dir');
230
+ // The default receipt sits at the destination root, so that root is already its own.
231
+ // Only a receipt the user placed elsewhere needs its own directory canonicalized, and
232
+ // doing that eagerly for the default would resolve the destination before preflight.
233
+ receiptRoot = request.receipt ? canonical(dirname(receiptPath)) : destination;
234
+ } else {
235
+ validate(request);
236
+ // The project root is the user's to name, so it is canonicalized here. The target suffix
237
+ // is ours, and a symlink planted in it is a preflight refusal, not bad input, so that
238
+ // walk happens in the preflight block below where the exit code is 3.
239
+ project = canonical(request.dir ?? process.cwd());
240
+ destination = resolve(project, targets[request.target]);
241
+ receiptPath = resolve(request.receipt ?? resolve(destination, receiptName));
242
+ receiptRoot = request.receipt ? canonical(dirname(receiptPath)) : destination;
243
+ outputRoot = request.outputDir === undefined ? null : canonical(request.outputDir);
244
+ }
245
+ } catch (error) { return result(2, error.message); }
246
+ if (request.uninstall) return uninstall(request, destination, receiptPath, receiptRoot);
247
+ try {
248
+ safePath(project, targets[request.target]);
249
+ if (request.receipt) safePath(receiptRoot, receiptPath);
250
+ if (inside(destination, receiptPath) && receiptPath === destination) throw Error('Receipt collides with destination');
251
+ entries = plan(request, destination);
252
+ for (const entry of entries) {
253
+ if (entries.some(other => other !== entry && inside(entry.path, other.path))) throw Error(`Planned file/directory collision: ${entry.path}`);
254
+ }
255
+ if (entries.some(entry => entry.path === receiptPath || inside(entry.path, receiptPath) || inside(receiptPath, entry.path))) throw Error('Receipt collides with installed content');
256
+ integration = integrationFor(resolve(request.dir ?? process.cwd()), destination, request.target);
257
+ const conflicts = [];
258
+ // Read the previous receipt FIRST, because it is what separates a file this package wrote
259
+ // on an earlier run from a file that was already on disk. Both are byte-identical now, and
260
+ // only one of them is ours to remove later.
261
+ if (stat(receiptPath)) {
262
+ try {
263
+ oldReceipt = readReceipt(receiptPath, destination, receiptRoot);
264
+ previous = fs.readFileSync(receiptPath, 'utf8');
265
+ } catch { conflicts.push(receiptPath); }
266
+ }
267
+ const ours = new Set((oldReceipt?.files ?? []).filter(entry => entry.action === 'written').map(entry => entry.path));
268
+ for (const entry of entries) {
269
+ const info = stat(entry.path);
270
+ const matches = Boolean(info?.isFile()) && fs.readFileSync(entry.path).equals(entry.bytes);
271
+ // `wrote` is what this run does; `action` is who the file belongs to afterwards.
272
+ entry.wrote = !matches;
273
+ entry.action = matches && !ours.has(entry.path) ? 'identical' : 'written';
274
+ if (info && entry.wrote) conflicts.push(entry.path);
275
+ }
276
+ if (conflicts.length) return result(3, `Preflight conflicts; nothing written:\n${conflicts.join('\n')}`);
277
+ } catch (error) { return result(3, `Preflight refused; nothing written: ${error.message}`); }
278
+ const receipt = {
279
+ schemaVersion: 1, package: packageInfo, date: new Date().toISOString(), mode: request.mode,
280
+ target: request.target, scope: request.scope ?? 'project', destination, outputRoot,
281
+ ...(request.outputStorage ? { outputStorage: request.outputStorage } : {}),
282
+ files: entries.map(entry => ({ path: entry.path, sha256: digest(entry.bytes), action: entry.action })),
283
+ directories: [], integration, activation: 'UNVERIFIED',
284
+ };
285
+ // An earlier run's files stay owned even when this run installs fewer of them, or a later
286
+ // uninstall would walk past its own TEAM bundles and call them somebody else's.
287
+ const carried = [];
288
+ if (oldReceipt) {
289
+ for (const entry of oldReceipt.files) {
290
+ if (receipt.files.some(current => current.path === entry.path)) continue;
291
+ const info = stat(entry.path);
292
+ if (info?.isFile() && digest(fs.readFileSync(entry.path)) === entry.sha256) carried.push(entry);
293
+ }
294
+ receipt.directories.push(...oldReceipt.directories);
295
+ }
296
+ const sameReceipt = oldReceipt && ['mode', 'target', 'scope', 'outputRoot'].every(key => oldReceipt[key] === receipt[key]) && JSON.stringify(oldReceipt.outputStorage) === JSON.stringify(receipt.outputStorage) && JSON.stringify(oldReceipt.package) === JSON.stringify(receipt.package) && JSON.stringify(oldReceipt.integration) === JSON.stringify(integration) && oldReceipt.files.length === receipt.files.length && receipt.files.every(entry => oldReceipt.files.some(old => old.path === entry.path && old.sha256 === entry.sha256));
297
+ const noChanges = entries.every(entry => !entry.wrote) && sameReceipt;
298
+ const lines = entries.map(entry => `${entry.wrote ? 'write' : 'identical'}: ${entry.path}`);
299
+ lines.push(`${noChanges ? 'identical' : 'write'} receipt: ${receiptPath}`);
300
+ if (request.mode === 'team') lines.push('TEAM uses manual relay: adapters/grok.md#manual-fallback. No native agent definitions were generated.');
301
+ if (integration.snippet) lines.push(`Completed install with activation pending: unmerged integration snippet for ${integration.routerPath}\n${integration.snippet}`);
302
+ if (request.dryRun) return result(0, `Dry run; no writes or probe.\n${outputRoot ? `Would probe: ${outputRoot}\n` : ''}${lines.join('\n')}`);
303
+ if (noChanges) return result(integration.snippet ? 5 : 0, `Identical install; nothing written.\n${lines.join('\n')}`);
304
+ try { if (outputRoot) probe(outputRoot); } catch (error) { return result(2, `Output root verification failed; no skill files written: ${error.message}`); }
305
+ const landed = [], made = [], receiptWrite = {};
306
+ try {
307
+ for (const entry of entries) {
308
+ safePath(destination, entry.path);
309
+ if (entry.wrote) writeNew(entry.path, entry.bytes, landed, destination, made);
310
+ else if (digest(fs.readFileSync(entry.path)) !== digest(entry.bytes)) throw Error(`File changed during install: ${entry.path}`);
311
+ }
312
+ // Read every file back before the receipt claims it. A digest taken from the bytes we
313
+ // meant to write is a record of the intention, not of what is on disk.
314
+ for (const entry of receipt.files) {
315
+ const actual = digest(fs.readFileSync(safePath(destination, entry.path)));
316
+ if (actual !== entry.sha256) throw Error(`Read-back mismatch after write: ${entry.path}`);
317
+ }
318
+ receipt.files.push(...carried);
319
+ receipt.directories = [...new Set([...receipt.directories, ...made])].filter(path => inside(destination, path) && path !== destination);
320
+ writeReceipt(receiptPath, receipt, previous, receiptWrite, receiptRoot, made);
321
+ } catch (error) {
322
+ receipt.files = receipt.files.filter(entry => !entries.find(planned => planned.path === entry.path)?.wrote || landed.includes(entry.path));
323
+ const notes = [];
324
+ receipt.files = receipt.files.filter(entry => {
325
+ try {
326
+ safePath(destination, entry.path);
327
+ const actual = digest(fs.readFileSync(entry.path));
328
+ if (!landed.includes(entry.path) && actual !== entry.sha256) { notes.push(`Kept changed during install: ${entry.path}`); return false; }
329
+ entry.sha256 = actual;
330
+ return true;
331
+ }
332
+ catch { notes.push(`Could not verify: ${entry.path}`); return false; }
333
+ });
334
+ receipt.files.push(...carried.filter(entry => stat(entry.path)?.isFile()));
335
+ receipt.directories = [...new Set([...receipt.directories, ...made])].filter(path => inside(destination, path) && path !== destination);
336
+ try {
337
+ // Retry our own interrupted receipt, without clobbering an unrelated arrival.
338
+ if (receiptWrite.started) { safePath(receiptRoot, receiptPath); previous = fs.readFileSync(receiptPath, 'utf8'); }
339
+ writeReceipt(receiptPath, receipt, previous, {}, receiptRoot, made);
340
+ } catch (failure) { notes.push(`Receipt could not be saved: ${failure.message}`); }
341
+ return result(4, `Write interrupted: ${error.message}\nPaths actually created:\n${landed.join('\n')}\n${notes.join('\n')}`);
342
+ }
343
+ return result(integration.snippet ? 5 : 0, `Install complete. Activation: UNVERIFIED.\n${lines.join('\n')}`);
344
+ }
@@ -0,0 +1,106 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { owned } from './bundle.mjs';
4
+
5
+ const library = 'research/library';
6
+ const columns = ['claimid', 'domain', 'statement', 'url', 'published', 'accessed', 'status', 'dependents'];
7
+ const key = value => value.toLowerCase().replace(/[ _-]/g, '');
8
+ const cells = line => line.trim().slice(1, -1).split(/(?<!\\)\|/).map(value => value.trim().replaceAll('\\|', '|'));
9
+
10
+ // Deliberately a small text contract, not a YAML parser. Excerpts use an indented
11
+ // literal block so page text cannot introduce receipt metadata fields.
12
+ function receiptFields(text) {
13
+ const fields = {}, problems = [];
14
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
15
+ for (let i = 0; i < lines.length; i++) {
16
+ const match = lines[i].match(/^(url|fetched_at|tool|result|published|excerpt):\s*(.*)$/);
17
+ if (!match) continue;
18
+ const [, name, value] = match;
19
+ if (Object.hasOwn(fields, name)) problems.push(`duplicate ${name}`);
20
+ if (name === 'excerpt' && value === '|') {
21
+ const passage = [];
22
+ while (i + 1 < lines.length && lines[i + 1].startsWith(' ')) passage.push(lines[++i].slice(2));
23
+ fields[name] = passage.join('\n');
24
+ } else fields[name] = value;
25
+ }
26
+ return { fields, problems };
27
+ }
28
+
29
+ function validUTC(value) {
30
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value || '')) return false;
31
+ const time = new Date(value);
32
+ const normalized = value.includes('.') ? value : value.replace('Z', '.000Z');
33
+ return !Number.isNaN(time.valueOf()) && time.toISOString() === normalized;
34
+ }
35
+
36
+ export function checkLibrary(siteWorkDir) {
37
+ const root = resolve(siteWorkDir), problems = [], unverified = [], rows = [];
38
+ const fail = (id, message) => problems.push(`${id}: ${message}`);
39
+ let ledger;
40
+ try { ledger = readFileSync(owned(root, `${library}/sources.md`), 'utf8'); }
41
+ catch { return { code: 2, message: 'FAIL sources.md: missing or unreadable research/library/sources.md', problems: ['sources.md: missing or unreadable'], unverified: [] }; }
42
+ let header = null;
43
+ for (const [index, line] of ledger.split(/\r?\n/).entries()) {
44
+ if (!line.trim().startsWith('|')) continue;
45
+ const location = `sources.md:${index + 1}`;
46
+ if (!line.trim().endsWith('|')) { fail(location, 'malformed ledger row'); continue; }
47
+ const row = cells(line);
48
+ if (row.every(cell => /^:?-+:?$/.test(cell))) continue;
49
+ if (key(row[0]) === 'claimid') {
50
+ header = row.map(key);
51
+ if (header.length !== columns.length || columns.some(name => !header.includes(name))) fail(location, 'expected Claim ID, Domain, Statement, URL, Published, Accessed, Status, Dependents columns');
52
+ continue;
53
+ }
54
+ if (!header || row.length !== header.length) { fail(location, 'missing header or inconsistent column count'); continue; }
55
+ rows.push({ ...Object.fromEntries(header.map((name, i) => [name, row[i]])), location });
56
+ }
57
+ if (!rows.length) fail('sources.md', 'no claim rows');
58
+ const ids = new Set(), unreceipted = [];
59
+ let opened = 0;
60
+ for (const row of rows) {
61
+ const id = row.claimid;
62
+ if (!/^CLAIM-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id || '')) { fail(row.location, 'invalid claim ID'); continue; }
63
+ if (ids.has(id)) fail(id, 'duplicate claim ID');
64
+ ids.add(id);
65
+ const status = (row.status || '').toUpperCase();
66
+ if (!['OPENED', 'VERIFIED', 'UNVERIFIED'].includes(status)) fail(id, 'status must be OPENED or UNVERIFIED');
67
+ if (status === 'UNVERIFIED') {
68
+ unverified.push(id);
69
+ // AD07: an UNVERIFIED claim may stay in the ledger as a lead, never as support.
70
+ // A non-empty Dependents cell is the ledger's own record that something relies on it.
71
+ if (!['', 'none', 'n/a', '-'].includes((row.dependents || '').trim().toLowerCase())) fail(id, 'UNVERIFIED claim is acted on; open the source and write its receipt, or clear Dependents');
72
+ }
73
+ else if (['OPENED', 'VERIFIED'].includes(status)) opened++;
74
+ const name = `${library}/receipts/${id}.md`;
75
+ let receipt;
76
+ try {
77
+ const path = owned(root, name);
78
+ if (!lstatSync(path).isFile()) throw Error('not a regular file');
79
+ receipt = readFileSync(path, 'utf8');
80
+ } catch {
81
+ unreceipted.push(id);
82
+ if (status !== 'UNVERIFIED') fail(id, `unreceipted claim; missing or unreadable ${name}`);
83
+ continue;
84
+ }
85
+ const { fields, problems: fieldProblems } = receiptFields(receipt);
86
+ for (const problem of fieldProblems) fail(id, problem);
87
+ for (const field of ['url', 'fetched_at', 'tool', 'result', 'published', 'excerpt']) {
88
+ if (!fields[field]?.trim()) fail(id, `receipt missing ${field}`);
89
+ }
90
+ if (fields.fetched_at && !validUTC(fields.fetched_at)) fail(id, 'fetched_at must be a valid UTC timestamp ending in Z');
91
+ if (fields.excerpt && [...fields.excerpt.trim()].length < 80) fail(id, 'excerpt is under 80 characters');
92
+ if (fields.url !== row.url) fail(id, 'receipt url differs from row URL');
93
+ if (row.published !== fields.published) fail(id, 'row publication date differs from receipt published field');
94
+ if (fields.published && fields.published !== 'unknown' && !fields.excerpt?.includes(fields.published)) fail(id, 'published date is not present in excerpt text');
95
+ }
96
+ if (unreceipted.length) {
97
+ for (const match of ledger.matchAll(/\bverified\s+against\s+opened\b/gi)) {
98
+ const line = ledger.slice(0, match.index).split('\n').length;
99
+ fail(`sources.md:${line}`, `verification claim over unreceipted rows: ${unreceipted.join(', ')}`);
100
+ }
101
+ }
102
+ const output = problems.map(problem => `FAIL ${problem}`);
103
+ output.push(...unverified.map(id => `UNVERIFIED ${id}: cannot support an acted-on claim`));
104
+ if (!problems.length) output.push(`PASS library: ${opened} OPENED, ${unverified.length} UNVERIFIED, ${rows.length} claims; receipt consistency only`);
105
+ return { code: problems.length ? 2 : 0, message: output.join('\n'), problems, unverified };
106
+ }