@rubytech/create-sitedesk-code 0.1.511 → 0.1.513

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 (48) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2028-declared-file-write-deny.md +303 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +126 -0
  4. package/payload/platform/lib/account-schema-regions/dist/index.d.ts +5 -0
  5. package/payload/platform/lib/account-schema-regions/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/account-schema-regions/dist/index.js +5 -0
  7. package/payload/platform/lib/account-schema-regions/dist/index.js.map +1 -1
  8. package/payload/platform/lib/account-schema-regions/src/index.ts +5 -0
  9. package/payload/platform/package.json +3 -0
  10. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +14 -0
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +38 -0
  12. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +11 -0
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +25 -1
  14. package/payload/platform/plugins/admin/skills/deck-pages/SKILL.md +2 -2
  15. package/payload/platform/plugins/admin/skills/skill-builder/SKILL.md +1 -1
  16. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  17. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +15 -34
  18. package/payload/platform/plugins/business-assistant/skills/e-sign/stamp.mjs +269 -0
  19. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +1 -1
  21. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +5 -5
  22. package/payload/platform/plugins/memory/skills/conversation-archive-enrich/SKILL.md +2 -2
  23. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +1 -1
  24. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  25. package/payload/platform/plugins/x-import/skills/x-import/SKILL.md +1 -1
  26. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +26 -0
  27. package/payload/platform/scripts/check-no-esm-require.mjs +6 -0
  28. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +40 -4
  29. package/payload/platform/scripts/logs-read.sh +154 -50
  30. package/payload/platform/scripts/logs-read.test.sh +276 -0
  31. package/payload/platform/scripts/provision-worktree.sh +80 -0
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +13 -0
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +7 -3
  36. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +17 -11
  38. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -1
  39. package/payload/server/{chunk-2WAXM5N2.js → chunk-S6HYTIU3.js} +2 -3
  40. package/payload/server/maxy-edge.js +7 -3
  41. package/payload/server/public/assets/admin-6QN-k3zQ.js +1 -0
  42. package/payload/server/public/assets/admin-6QN-k3zQ.js.br +0 -0
  43. package/payload/server/public/assets/admin-6QN-k3zQ.js.gz +0 -0
  44. package/payload/server/public/index.html +1 -1
  45. package/payload/server/server.js +860 -384
  46. package/payload/server/public/assets/admin-Do53K90C.js +0 -1
  47. package/payload/server/public/assets/admin-Do53K90C.js.br +0 -0
  48. package/payload/server/public/assets/admin-Do53K90C.js.gz +0 -0
@@ -394,9 +394,9 @@ export default function DeckLayout({ children }: { children: React.ReactNode })
394
394
 
395
395
  The page component (`app/deck/page.tsx`) is a `"use client"` component containing the slides, the download function, and the download button. Keep the layout as a server component for metadata; the "use client" directive belongs on the page, not the layout.
396
396
 
397
- ## Reference implementation
397
+ ## Reference arc
398
398
 
399
- A complete working deck lives at `/Users/neo/capacity-derivatives/app/deck/` — read `layout.tsx` and `page.tsx` there when building a new deck. The reference shows eight slides covering title, problem, market, solution, differentiation, roadmap, competitive landscape, and team/ask the standard pitch-deck arc.
399
+ A complete deck runs eight slides title, problem, market, solution, differentiation, roadmap, competitive landscape, and team/ask. That is the standard pitch-deck arc; deviate only when the subject demands it.
400
400
 
401
401
  ## Checklist before shipping
402
402
 
@@ -83,7 +83,7 @@ Show the user the complete SKILL.md content and each reference file. Ask them to
83
83
 
84
84
  Use `store-skill` to write the composed skill to disk as part of an admin-managed plugin. The tool computes the path internally — supply the names and content only.
85
85
 
86
- Pick a `pluginName` that groups related operator-authored skills (e.g. `beacons-skills`, `my-utils`). Reuse the same `pluginName` across calls so the skill joins existing operator skills under one plugin. If this is the first skill in a new plugin, the tool creates the `PLUGIN.md` automatically.
86
+ Pick a `pluginName` that groups related operator-authored skills (e.g. `sales-skills`, `my-utils`). Reuse the same `pluginName` across calls so the skill joins existing operator skills under one plugin. If this is the first skill in a new plugin, the tool creates the `PLUGIN.md` automatically.
87
87
 
88
88
  ```
89
89
  store-skill({
@@ -9,6 +9,18 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-27 (0.1.513)
13
+
14
+ - Fixed sign-in for sub-accounts: the browser session bridge now checks that you belong to the account you asked for, instead of only ever accepting the first one it found.
15
+ - WhatsApp account checks now use the account's real identifier rather than a folder name, so conversations are attributed to the right account.
16
+ - The saved session-title cache is now pruned as the sessions list is walked, so it no longer grows without limit.
17
+
18
+ ## 2026-07-27 (0.1.512)
19
+
20
+ - Signed PDFs keep their clickable links and selectable text. The e-sign stamp no longer falls back to flattening each page into an image, which had been discarding every link in the document.
21
+ - The sessions list is much faster: it no longer reads every account's transcripts in full just to build the list, and titles now come from a cache.
22
+ - Fixed a dashboard error that repeated once a minute when you were switched to a sub-account, caused by asking for a conversations list only the main account is allowed to read.
23
+
12
24
  ## 2026-07-26 (0.1.511)
13
25
 
14
26
  - Fixed a dashboard bug where the Host CPU card could sit on a loading skeleton for a full minute after its figure had already arrived, even though the sidebar was showing the same number.
@@ -477,47 +477,28 @@ Dispatch runs **only** inside a live operator session on the account's own devic
477
477
 
478
478
  2. **For each unswept row, in order — stamp, send, then mark that one row swept:**
479
479
 
480
- - **Stamp a per-signer copy from the persisted base no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it, all with a PDF library that loads the base and copies its pages through intact (**`pdf-lib`**, run as a small Node script). The stamp copies existing pages; it never renders them to an image. Rasterising the base (ghostscript to PNG, or any image-then-redraw rebuild) discards every link annotation and all selectable text, so the signer receives a flattened copy with dead links; it is prohibited whatever is or is not installed. A substitute for pdf-lib is allowed only if it imports existing PDF pages: pypdf, qpdf, or pdftk. A canvas-only generator such as reportlab cannot import a base and is not a substitute. Whatever the library, it adds no browser and no network call.
481
-
482
- **Pre-flight: the page-copying library must resolve before any stamp runs.** A stamp attempted without it is the exact history this guards against, where a prior session met the missing library by rasterising instead of installing one:
480
+ - **Stamp a per-signer copy from the persisted base with the platform's stamp script.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. The stamp is platform code, not something to compose at dispatch time:
483
481
 
484
482
  ```bash
485
- node -e 'import("pdf-lib").then(()=>console.log("ok"),e=>{console.error("pdf-lib missing: npm i pdf-lib at the account directory root");process.exit(1)})'
483
+ DIR="${ACCOUNT_DIR}/e-sign/<DOC_REF>" \
484
+ CERT='{"name":"…","title":"…","token":"…","doc_ref":"…","accepted_at":"…","method":"…","assertion":"…"}' \
485
+ node "$PLATFORM_ROOT/plugins/business-assistant/skills/e-sign/stamp.mjs"
486
486
  ```
487
487
 
488
- On failure the one remediation is that install; a fallback that renders pages to images is never one. The stamp script itself:
489
-
490
- ```js
491
- // stamp.mjs — node, offline. Reads the persisted base, appends a certificate page,
492
- // writes ${ACCOUNT_DIR}/e-sign/<DOC_REF>/signed-<token>.pdf. No network, no browser.
493
- import { readFileSync, writeFileSync } from "node:fs";
494
- import { PDFDocument, StandardFonts } from "pdf-lib";
495
-
496
- const dir = process.env.DIR; // ${ACCOUNT_DIR}/e-sign/<DOC_REF>
497
- const cert = JSON.parse(process.env.CERT); // {name,title,token,doc_ref,accepted_at,method,assertion}
498
- const pdf = await PDFDocument.load(readFileSync(`${dir}/base.pdf`));
499
- const font = await pdf.embedFont(StandardFonts.Helvetica);
500
- const page = pdf.addPage();
501
- const lines = [
502
- "Electronic Acceptance Certificate",
503
- `Name: ${cert.name}`,
504
- `Title: ${cert.title ?? "-"}`,
505
- `Doc ref: ${cert.doc_ref}`,
506
- `Token: ${cert.token}`,
507
- `Accepted: ${cert.accepted_at}`,
508
- `Method: ${cert.method}`, // e.g. "simple electronic signature (UK ECA 2000 s.7 / UK eIDAS)"
509
- "",
510
- "Assertion the signer ticked:",
511
- cert.assertion, // the verbatim intent/consent text from § 2
512
- ];
513
- let y = page.getHeight() - 60;
514
- for (const ln of lines) { page.drawText(ln, { x: 50, y, size: 11, font }); y -= 20; }
515
- writeFileSync(`${dir}/signed-${cert.token}.pdf`, await pdf.save());
488
+ It loads `base.pdf`, appends the **Electronic Acceptance Certificate** page, and writes `signed-<token>.pdf` beside the base. No browser, no network, and no library to install: `pdf-lib` is a platform dependency that every install provides.
489
+
490
+ **Never write your own stamp.** This is not a style preference. A session that found no PDF library on the box once answered it by rasterising the base to a 200 DPI image and redrawing it, which discarded every link annotation and all selectable text. Four signers were sent flattened copies with dead links and nothing logged it. Rasterising a base, whether through ghostscript or any other image-then-redraw route, is prohibited whatever is or is not installed on the device.
491
+
492
+ **Pre-flight.** An absent script means the install is out of date, and the single remediation is to upgrade it through the installer. Never hand-install a library on a device, and never substitute a stamp of your own while waiting:
493
+
494
+ ```bash
495
+ test -f "$PLATFORM_ROOT/plugins/business-assistant/skills/e-sign/stamp.mjs" \
496
+ || echo "e-sign stamp missing: upgrade this install, do not improvise a stamp"
516
497
  ```
517
498
 
518
- The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis.
499
+ The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis. The script refuses to run on an incomplete certificate.
519
500
 
520
- **Post-stamp gate: prove fidelity before any send.** For both `base.pdf` and the stamped copy, count link annotations with `grep -a -c '/URI'` and measure page-1 text extent with ghostscript text extraction (`gs -q -dNOPAUSE -dBATCH -sDEVICE=txtwrite -dFirstPage=1 -dLastPage=1 -o - <file> | wc -c`). The stamped copy passes only when its `/URI` count and its page-1 text extent are each at least the base's. On either regression the stamp rasterised or otherwise flattened the base, so delete the stamped copy, skip both sends, and leave the row `swept = 0` so the next sweep re-surfaces it. This gate converts a link-stripped copy from a no-event failure (nothing logs until a signer clicks a dead link) into a rejection at stamp time. Record the base and stamped `/URI` counts alongside every dispatch, pass or fail, so a fidelity regression is a number rather than a complaint.
501
+ **The fidelity gate lives inside the script, so it cannot be skipped.** Before writing anything it checks the stamped copy against the base on three counts: every base page must come through byte-identical, the page count must be the base plus the certificate pages, and the `/URI` link count must not drop. On any regression it writes no file and exits non-zero, in which case both sends are skipped and the row stays `swept = 0` so the next sweep re-surfaces it. That converts a link-stripped copy from a no-event failure, where nothing logs until a signer clicks a dead link, into a refusal at stamp time. A zero exit prints one `[e-sign-stamp]` line carrying the base and stamped `/URI` counts; keep it with the dispatch record so a fidelity regression is a number rather than a complaint.
521
502
 
522
503
  - **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the **stamped** PDF (`signed-<token>.pdf`) as an attachment:
523
504
  - once to the **business owner's configured address** (read from configured business identity — never hard-code a recipient in this workflow);
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stamp.mjs — the e-sign per-signer stamp.
4
+ *
5
+ * Loads the persisted `base.pdf` for one DOC_REF, appends an Electronic
6
+ * Acceptance Certificate page carrying the D1 row's fields, and writes
7
+ * `signed-<token>.pdf` beside the base. No browser, no network.
8
+ *
9
+ * This ships with the platform rather than being written out by the agent at
10
+ * dispatch time, because the agent-authored version was the defect: a session
11
+ * that met a missing PDF library answered it by rasterising the base to PNG and
12
+ * redrawing it, which discarded every link annotation and all selectable text.
13
+ * Signers received flattened copies with dead links and nothing logged it
14
+ * (Task 1921). `pdf-lib` is a platform dependency, installed on every device by
15
+ * the installer's `npm install` in `platform/`, so the missing-library branch
16
+ * that led to rasterising cannot arise.
17
+ *
18
+ * The fidelity gate below is what makes that guarantee enforceable rather than
19
+ * merely intended: the stamped copy is written only when every base page came
20
+ * through byte-identical and no `/URI` link was lost. A rebuild-from-image
21
+ * implementation cannot pass it.
22
+ *
23
+ * Invocation (the skill's § 6 dispatch step):
24
+ *
25
+ * DIR=<accountDir>/e-sign/<DOC_REF> \
26
+ * CERT='{"name":…,"title":…,"token":…,"doc_ref":…,"accepted_at":…,"method":…,"assertion":…}' \
27
+ * node "$PLATFORM_ROOT/plugins/business-assistant/skills/e-sign/stamp.mjs"
28
+ *
29
+ * Exit 0 writes the copy and prints one `[e-sign-stamp]` record line carrying
30
+ * the base and stamped `/URI` counts. Any non-zero exit means no copy was
31
+ * written and the acceptance row must stay `swept = 0`.
32
+ */
33
+
34
+ import { createHash } from "node:crypto";
35
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs";
36
+ import { join } from "node:path";
37
+ import { pathToFileURL } from "node:url";
38
+ import { PDFArray, PDFDocument, PDFName, PDFRef, StandardFonts } from "pdf-lib";
39
+
40
+ const MARGIN = 50;
41
+ const TITLE_SIZE = 14;
42
+ const BODY_SIZE = 11;
43
+ const LINE_HEIGHT = 20;
44
+
45
+ /** The certificate's fields, in the order § 6 of the skill declares them. */
46
+ export function certificateLines(cert) {
47
+ return [
48
+ { text: "Electronic Acceptance Certificate", size: TITLE_SIZE },
49
+ { text: `Name: ${cert.name}` },
50
+ { text: `Title: ${cert.title ?? "-"}` },
51
+ { text: `Doc ref: ${cert.doc_ref}` },
52
+ { text: `Token: ${cert.token}` },
53
+ { text: `Accepted: ${cert.accepted_at}` },
54
+ { text: `Method: ${cert.method}` },
55
+ { text: "" },
56
+ { text: "Assertion the signer ticked:" },
57
+ { text: cert.assertion, wrap: true },
58
+ ];
59
+ }
60
+
61
+ /** Break `text` so each line fits `maxWidth` at `size`. Long unbreakable tokens
62
+ * are emitted on their own line rather than dropped. */
63
+ export function wrapText(text, font, size, maxWidth) {
64
+ const words = String(text).split(/\s+/).filter(Boolean);
65
+ if (words.length === 0) return [""];
66
+ const lines = [];
67
+ let line = "";
68
+ for (const word of words) {
69
+ const candidate = line ? `${line} ${word}` : word;
70
+ if (font.widthOfTextAtSize(candidate, size) <= maxWidth || !line) {
71
+ line = candidate;
72
+ } else {
73
+ lines.push(line);
74
+ line = word;
75
+ }
76
+ }
77
+ if (line) lines.push(line);
78
+ return lines;
79
+ }
80
+
81
+ function resolve(doc, obj) {
82
+ return obj instanceof PDFRef ? doc.context.lookup(obj) : obj;
83
+ }
84
+
85
+ /** SHA-256 of a page's concatenated content streams. Two pages with the same
86
+ * digest carry the same drawing operators; a page re-rendered from an image
87
+ * never matches the page it replaced. */
88
+ export function pageContentDigest(doc, page) {
89
+ const parts = [];
90
+ const collect = (obj) => {
91
+ const value = resolve(doc, obj);
92
+ if (!value) return;
93
+ if (value instanceof PDFArray) {
94
+ for (const entry of value.asArray()) collect(entry);
95
+ return;
96
+ }
97
+ if (typeof value.getContents === "function") {
98
+ parts.push(Buffer.from(value.getContents()));
99
+ return;
100
+ }
101
+ if (value.contents) parts.push(Buffer.from(value.contents));
102
+ };
103
+ collect(page.node.get(PDFName.of("Contents")));
104
+ return createHash("sha256").update(Buffer.concat(parts)).digest("hex");
105
+ }
106
+
107
+ /** Annotations on `page` whose action dictionary carries a `/URI` — the
108
+ * external hyperlinks the signer is meant to be able to click. */
109
+ export function uriLinkCount(doc, page) {
110
+ const annots = resolve(doc, page.node.get(PDFName.of("Annots")));
111
+ if (!(annots instanceof PDFArray)) return 0;
112
+ let count = 0;
113
+ for (const entry of annots.asArray()) {
114
+ const annot = resolve(doc, entry);
115
+ if (typeof annot?.get !== "function") continue;
116
+ const action = resolve(doc, annot.get(PDFName.of("A")));
117
+ if (typeof action?.get !== "function") continue;
118
+ if (action.get(PDFName.of("URI")) !== undefined) count += 1;
119
+ }
120
+ return count;
121
+ }
122
+
123
+ /** Per-page digests and `/URI` counts, in page order. */
124
+ export async function pageFingerprints(bytes) {
125
+ const doc = await PDFDocument.load(bytes);
126
+ return doc.getPages().map((page) => ({
127
+ digest: pageContentDigest(doc, page),
128
+ uris: uriLinkCount(doc, page),
129
+ }));
130
+ }
131
+
132
+ /**
133
+ * Reject the stamped bytes unless every base page survived unaltered.
134
+ *
135
+ * Three conditions, each naming a distinct way a stamp can destroy the document
136
+ * it was supposed to preserve:
137
+ * 1. page count, which catches base pages lost between building the document
138
+ * and reading back what was serialised;
139
+ * 2. content digest, which catches a page redrawn (the rasterisation failure);
140
+ * 3. per-page `/URI` count, which catches annotations discarded while the
141
+ * visible page survives. Compared page by page rather than as a document
142
+ * total, so links moved off one page cannot be masked by another.
143
+ */
144
+ export async function assertFidelity(baseBytes, stampedBytes, addedPages) {
145
+ const base = await pageFingerprints(baseBytes);
146
+ const stamped = await pageFingerprints(stampedBytes);
147
+
148
+ const expected = base.length + addedPages;
149
+ if (stamped.length !== expected) {
150
+ throw new Error(
151
+ `page count regressed: base ${base.length} + ${addedPages} certificate page(s) ` +
152
+ `should give ${expected}, stamped copy has ${stamped.length}`,
153
+ );
154
+ }
155
+
156
+ for (let i = 0; i < base.length; i += 1) {
157
+ if (stamped[i].digest !== base[i].digest) {
158
+ throw new Error(
159
+ `page ${i + 1} was re-rendered rather than copied — its content stream does not ` +
160
+ `match the base. A stamp must copy existing pages; rasterising or redrawing them ` +
161
+ `discards links and selectable text.`,
162
+ );
163
+ }
164
+ if (stamped[i].uris < base[i].uris) {
165
+ throw new Error(
166
+ `link annotations lost on page ${i + 1}: base carries ${base[i].uris} /URI link(s), ` +
167
+ `stamped copy carries ${stamped[i].uris}`,
168
+ );
169
+ }
170
+ }
171
+
172
+ const baseUris = base.reduce((n, p) => n + p.uris, 0);
173
+ const stampedUris = stamped.reduce((n, p) => n + p.uris, 0);
174
+ return { basePages: base.length, stampedPages: stamped.length, baseUris, stampedUris };
175
+ }
176
+
177
+ /** Load the base, append the certificate, and return the stamped bytes plus the
178
+ * number of pages the certificate needed. */
179
+ export async function buildStamped(baseBytes, cert) {
180
+ const pdf = await PDFDocument.load(baseBytes);
181
+ const basePages = pdf.getPageCount();
182
+ if (basePages === 0) throw new Error("base.pdf has no pages");
183
+
184
+ const font = await pdf.embedFont(StandardFonts.Helvetica);
185
+ const [width, height] = [pdf.getPage(0).getWidth(), pdf.getPage(0).getHeight()];
186
+ const maxWidth = width - MARGIN * 2;
187
+
188
+ let page = pdf.addPage([width, height]);
189
+ let y = height - MARGIN - LINE_HEIGHT;
190
+ const draw = (text, size) => {
191
+ if (y < MARGIN) {
192
+ page = pdf.addPage([width, height]);
193
+ y = height - MARGIN - LINE_HEIGHT;
194
+ }
195
+ if (text) page.drawText(text, { x: MARGIN, y, size, font });
196
+ y -= LINE_HEIGHT;
197
+ };
198
+
199
+ try {
200
+ for (const line of certificateLines(cert)) {
201
+ const size = line.size ?? BODY_SIZE;
202
+ if (line.wrap) {
203
+ for (const wrapped of wrapText(line.text, font, size, maxWidth)) draw(wrapped, size);
204
+ } else {
205
+ draw(line.text, size);
206
+ }
207
+ }
208
+ } catch (err) {
209
+ // Helvetica is WinAnsi-encoded, so a signer name or assertion carrying a
210
+ // character outside CP-1252 cannot be drawn. Say so in terms that keep the
211
+ // caller on the supported path: the remedy is a platform change that embeds
212
+ // a Unicode font, never a stamp improvised with a different library.
213
+ throw new Error(
214
+ `the certificate carries a character the standard font cannot encode (${err.message}). ` +
215
+ `This needs a platform fix that embeds a Unicode font. Do not improvise a stamp: ` +
216
+ `leave the row swept = 0 and report it.`,
217
+ );
218
+ }
219
+
220
+ return { bytes: await pdf.save(), addedPages: pdf.getPageCount() - basePages };
221
+ }
222
+
223
+ const REQUIRED_CERT_FIELDS = ["name", "token", "doc_ref", "accepted_at", "method", "assertion"];
224
+
225
+ async function main() {
226
+ const dir = process.env.DIR;
227
+ if (!dir) throw new Error("DIR is not set — it must be <accountDir>/e-sign/<DOC_REF>");
228
+ if (!process.env.CERT) throw new Error("CERT is not set — it must be the D1 row's certificate fields as JSON");
229
+
230
+ let cert;
231
+ try {
232
+ cert = JSON.parse(process.env.CERT);
233
+ } catch (err) {
234
+ throw new Error(`CERT is not valid JSON: ${err.message}`);
235
+ }
236
+ const missing = REQUIRED_CERT_FIELDS.filter((field) => cert[field] === undefined || cert[field] === "");
237
+ if (missing.length > 0) {
238
+ throw new Error(
239
+ `CERT is missing ${missing.join(", ")} — every field comes from the acceptance row, never a re-fetch`,
240
+ );
241
+ }
242
+
243
+ const basePath = join(dir, "base.pdf");
244
+ const baseBytes = readFileSync(basePath);
245
+ const { bytes, addedPages } = await buildStamped(baseBytes, cert);
246
+ const counts = await assertFidelity(baseBytes, bytes, addedPages);
247
+
248
+ const outPath = join(dir, `signed-${cert.token}.pdf`);
249
+ writeFileSync(outPath, bytes);
250
+ console.log(
251
+ `[e-sign-stamp] op=stamped doc_ref=${cert.doc_ref} token=${cert.token} ` +
252
+ `base_pages=${counts.basePages} stamped_pages=${counts.stampedPages} ` +
253
+ `base_uris=${counts.baseUris} stamped_uris=${counts.stampedUris} out=${outPath}`,
254
+ );
255
+ }
256
+
257
+ // Node realpaths the ESM entry point, so `import.meta.url` is symlink-resolved
258
+ // and `process.argv[1]` is not. Comparing them raw makes a symlinked
259
+ // PLATFORM_ROOT skip main() and exit 0 having stamped nothing, which is exactly
260
+ // the silent success this script exists to make impossible.
261
+ const invokedDirectly =
262
+ process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
263
+
264
+ if (invokedDirectly) {
265
+ main().catch((err) => {
266
+ console.error(`[e-sign-stamp] op=failed reason="${err.message}" — no copy written, leave the row swept = 0`);
267
+ process.exit(1);
268
+ });
269
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: cloudflare
3
3
  description: Cloudflare operations — tunnel setup/reset, DNS, Pages hosting, D1 data capture, and dashboard guidance. Zero agent-facing MCP tools; every operation is the agent invoking `cloudflared` or `wrangler` directly via Bash, calling the Cloudflare API with a reused per-scope narrow token, or quoting a dashboard click-path the operator performs themselves.
4
- account-owned-files: [{"file": "data-portal.json", "description": "The account's data-portal folder index, written by bin/portal-index-push.mjs on every push."}]
4
+ account-owned-files: [{"file": "data-portal.json", "writtenBy": "agent", "description": "The account's portal config and folder index. Authored by the agent under the data-portal skill, which is also the only path that changes it; bin/portal-index-push.mjs reads it on every push."}]
5
5
  tools: []
6
6
  mcp-manifest: skip
7
7
  ---
@@ -78,7 +78,7 @@ export function resolveExposedDirs(schemaText, exposeFolders = []) {
78
78
  collisions: /** @type {string[]} */ ([]),
79
79
  exposed: /** @type {string[]} */ ([]),
80
80
  }
81
- // Fail CLOSED. fs-schema-guard.sh:76-78 fails open on a missing schema so an
81
+ // Fail CLOSED. fs-schema-guard.sh:77-80 fails open on a missing schema so an
82
82
  // unseeded legacy account is not write-blocked; a read surface facing a
83
83
  // client must do the opposite. Do not "fix" this to match the guard.
84
84
  if (typeof schemaText !== 'string' || schemaText.length === 0) return empty
@@ -40,18 +40,18 @@ This reference invokes `mcp__plugin_memory_memory__memory-update` once with the
40
40
  {
41
41
  "nodeId": "<elementId of :UserProfile from owner-confirmation>",
42
42
  "properties": {
43
- "givenName": "Joel",
44
- "familyName": "Smalley",
43
+ "givenName": "<given name>",
44
+ "familyName": "<family name>",
45
45
  "additionalName": null,
46
46
  "address": null,
47
47
  "birthDate": null,
48
- "headline": "Founder, Rubytech",
48
+ "headline": "Founder, <company>",
49
49
  "description": "Building {{productName}} …",
50
50
  "industry": "Software",
51
51
  "postalCode": null,
52
52
  "addressLocality": "London, UK",
53
- "twitterHandles": ["@joelsmalley"],
54
- "websites": ["https://getmaxy.com"],
53
+ "twitterHandles": ["@<handle>"],
54
+ "websites": ["https://example.com"],
55
55
  "instantMessengers": [],
56
56
  "linkedinProfileUpdatedAt": "<ISO 8601 timestamp>",
57
57
  "source": "linkedin"
@@ -15,8 +15,8 @@ The LLM step runs in the specialist's turn. There is no server-side Haiku round-
15
15
 
16
16
  When the operator names a specific archive AND asks for insights, enrichment, or derived claims against it. Concrete triggers:
17
17
 
18
- - "Derive insights from the Adam Mackay WhatsApp archive."
19
- - "Enrich the Joel × Adam conversation archive — surface anything actionable."
18
+ - "Derive insights from the WhatsApp archive for <contact>."
19
+ - "Enrich the <contact-a> × <contact-b> conversation archive — surface anything actionable."
20
20
  - "Run Phase 2 on `:ConversationArchive elementId=4:abcd…:42`."
21
21
 
22
22
  Never auto-fire after a Phase 1 ingest completes — `conversation-archive` (the ingest skill) explicitly writes ZERO observations. Phase 2 is operator-gated by design.
@@ -206,7 +206,7 @@ Re-ingesting the same `attachmentId` is safe — the writer drops prior `:Sectio
206
206
 
207
207
  After `memory-ingest` returns the new KnowledgeDocument's `documentNodeId`, this step iterates the entities the dispatch brief named and connects each to the new document with the natural KD-level edge.
208
208
 
209
- **Entity sources.** The dispatch brief's "key entities to connect" list. Brief shape: prose names of Persons, Organizations, Services, Tasks, Events, KnowledgeDocuments, BrandingData that the document describes or references. Example: *"Person nodes for Joel Smalley, Adam Mackay, Dan McLeod; LocalBusiness / Organization nodes for Real Agent / Real Agency; Any existing Task nodes related to Real Agent Lettings."* Extract every named entity from the brief before any `memory-write`.
209
+ **Entity sources.** The dispatch brief's "key entities to connect" list. Brief shape: prose names of Persons, Organizations, Services, Tasks, Events, KnowledgeDocuments, BrandingData that the document describes or references. Example: *"Person nodes for each named individual; LocalBusiness / Organization nodes for each named company; any existing Task nodes related to the engagement the document describes."* Extract every named entity from the brief before any `memory-write`.
210
210
 
211
211
  **Resolution.** For each named entity:
212
212
  2. If the entity resolves to exactly one node, write the edge from KD to that node (see edge-type table below).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: scheduling
3
3
  description: "Calendar and scheduling — create events, manage appointments, set up recurring triggers."
4
- account-owned-files: [{"file": "calendar-availability.json", "description": "The booking page's availability config, written by the publish-availability script and read by the live booking page."}]
4
+ account-owned-files: [{"file": "calendar-availability.json", "writtenBy": "agent", "description": "The booking page's availability config. Authored by the agent under the calendar-site skill, which is also the only path that changes it; the publish-availability script reads it to refresh the live booking page."}]
5
5
  tools:
6
6
  - name: schedule-event
7
7
  publicAllowlist: false
@@ -57,7 +57,7 @@ Dispatch the existing librarian / document-ingest pipeline against the rendered
57
57
  - `mimeType = "text/markdown"`
58
58
 
59
59
  The dispatch brief names:
60
- - **Anchor:** `:AdminUser` (the confirmed owner from Phase 0). Adam Mackay / Joel Smalley etc. resolved in Phase 0 appear in the **key entities to connect** list — `wire-brief-entities` writes the `MENTIONED` / `REPLIED_TO` / `QUOTED` edges from the new KD to each `:Person`.
60
+ - **Anchor:** `:AdminUser` (the confirmed owner from Phase 0). Every `:Person` resolved in Phase 0 appears in the **key entities to connect** list — `wire-brief-entities` writes the `MENTIONED` / `REPLIED_TO` / `QUOTED` edges from the new KD to each `:Person`.
61
61
  - **`sourceUrl`:** `https://x.com/<handle>`
62
62
  - **`sourceType`:** `'x'` (carried into `:KnowledgeDocument.source`)
63
63
  - **`scope`:** `'admin'` (the tweet stream is the operator's authored content)
@@ -395,5 +395,31 @@ for _n in e-sign data-portal.json calendar-availability.json wa-channel-bindings
395
395
  assert_grep "$_n" "$ALLOWED_SHIP" "ship-allow-$_n"
396
396
  done
397
397
 
398
+ # --- declared-files fence: one name<TAB>plugin line per declared FILE -------
399
+ # The allowed-top-level fence is a layout list: it says a name may exist at the
400
+ # account root, not that an agent may author it. The write guard needs the file
401
+ # set apart from the bucket set, plus the owning plugin to name in its block
402
+ # message, so the merge emits it as its own fence.
403
+ DECL_SHIP="$(awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$A_SHIP/SCHEMA.md")"
404
+ for _n in wa-channel-bindings.json telegram-channel-bindings.json webchat-channel-bindings.json \
405
+ canonical-webchat-session.json session-titles.json agents-disabled.json; do
406
+ assert_grep "$(printf '%s\tadmin' "$_n")" "$DECL_SHIP" "ship-decl-$_n"
407
+ done
408
+ # An agent-written declaration (writtenBy: agent) stays OUT of the fence: the
409
+ # calendar-site and data-portal skills author these two files with Write, so
410
+ # denying them would break the only path that creates them. They remain in the
411
+ # allowed set (asserted above), so the reconcile still never calls them strays.
412
+ assert_nogrep "data-portal.json" "$DECL_SHIP" "ship-decl-excludes-agent-written-portal"
413
+ assert_nogrep "calendar-availability.json" "$DECL_SHIP" "ship-decl-excludes-agent-written-availability"
414
+ # A declared DIR never enters the file fence.
415
+ assert_nogrep "e-sign" "$DECL_SHIP" "ship-decl-excludes-dirs"
416
+ assert_grep "declared-files:start" "$(cat "$A_SHIP/SCHEMA.md")" "ship-decl-marker"
417
+ # Idempotent: a second merge leaves the file byte-identical.
418
+ B_SHIP="$(shasum "$A_SHIP/SCHEMA.md" | awk '{print $1}')"
419
+ merge_owned_dirs_into_schema "$A_SHIP" >/dev/null
420
+ assert_eq "$(shasum "$A_SHIP/SCHEMA.md" | awk '{print $1}')" "$B_SHIP" "ship-decl-idempotent"
421
+ # A brand whose payload declares no owned file emits no region at all.
422
+ assert_nogrep "declared-files:start" "$(cat "$A_MX/SCHEMA.md")" "mx-no-decl-region"
423
+
398
424
  echo "PASS=$PASS FAIL=$FAIL"
399
425
  if [ "$FAIL" -ne 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
@@ -44,6 +44,12 @@ const ALLOWLIST = new Set([
44
44
  // Same vi.hoisted pattern: Task 549's sidebar-sessions test pre-builds a
45
45
  // tmpdir before the module-under-test's ACCOUNTS_DIR mock is wired.
46
46
  'platform/ui/server/routes/admin/__tests__/sidebar-sessions.test.ts',
47
+ // Same vi.hoisted pattern: Task 2035's sidebar-sessions walk test pre-builds
48
+ // its projects tmpdir before the ACCOUNTS_DIR mock is wired.
49
+ 'platform/ui/server/routes/admin/__tests__/sidebar-sessions-walk.test.ts',
50
+ // Same vi.hoisted pattern: Task 2035's reader title-cache test pre-builds its
51
+ // accounts tmpdir before the ACCOUNTS_DIR mock is wired.
52
+ 'platform/ui/server/routes/__tests__/whatsapp-reader-title-cache.test.ts',
47
53
  // Same vi.hoisted pattern: Task 684's sidebar-artefacts test pre-builds an
48
54
  // empty tmpdir for ACCOUNTS_DIR/PLATFORM_ROOT before the module's mocks fire.
49
55
  'platform/ui/server/routes/admin/__tests__/sidebar-artefacts.test.ts',
@@ -8,6 +8,13 @@ by kind:
8
8
  account-owned-dirs: [{"dir": "quoting", "description": "..."}]
9
9
  account-owned-files: [{"file": "data-portal.json", "description": "..."}]
10
10
 
11
+ A file entry may add "writtenBy": "agent" to say the agent authors it under a
12
+ skill's instruction rather than platform code writing it whole. Such a file is
13
+ still declared — the reconcile must not call it a stray — but it is left out of
14
+ the declared-files fence the write guard denies on, because denying it would
15
+ block the only path that creates it. Absent or any other value means platform
16
+ code is the writer.
17
+
11
18
  The owned-entry set for a brand is the union declared by every platform plugin
12
19
  ($PROJECT_DIR/plugins/*/PLUGIN.md) and every premium sub-plugin of a bundle the
13
20
  brand ships ($PREMIUM_ROOT/<bundle>/plugins/*/PLUGIN.md, gated by brand.json
@@ -30,6 +37,8 @@ MARK_START = "<!-- plugin-owned-dirs:start -->"
30
37
  MARK_END = "<!-- plugin-owned-dirs:end -->"
31
38
  ONT_START = "<!-- ontology-buckets:start -->"
32
39
  ONT_END = "<!-- ontology-buckets:end -->"
40
+ DECL_START = "<!-- declared-files:start -->"
41
+ DECL_END = "<!-- declared-files:end -->"
33
42
 
34
43
  # Base top-level names shipped in the account template plus `.quarantine`. This
35
44
  # set is prune-protection only: a reconcile never *adds* a missing seed entry,
@@ -109,7 +118,9 @@ def _read_vertical(project_dir):
109
118
 
110
119
 
111
120
  def _parse_plugin_md(path):
112
- """Return (name, [ {name, kind, description} ]) from a PLUGIN.md, or (None, []).
121
+ """Return (name, [ {name, kind, description, writtenBy} ]) from a PLUGIN.md,
122
+ or (None, []). `writtenBy` is '' unless the entry states one; 'agent' keeps
123
+ the name out of the declared-files fence (see the module docstring).
113
124
  Two frontmatter keys, one list: `account-owned-dirs` yields kind='dir',
114
125
  `account-owned-files` yields kind='file'. A key whose value is not a JSON
115
126
  array of objects is skipped WHOLE: entries are staged and committed only once
@@ -145,7 +156,8 @@ def _parse_plugin_md(path):
145
156
  n = (e.get(field) or "").strip()
146
157
  if n and "/" not in n:
147
158
  staged.append({"name": n, "kind": kind,
148
- "description": (e.get("description") or "").strip()})
159
+ "description": (e.get("description") or "").strip(),
160
+ "writtenBy": (e.get("writtenBy") or "").strip()})
149
161
  except Exception:
150
162
  continue
151
163
  owned.extend(staged)
@@ -190,7 +202,8 @@ def resolve(project_dir):
190
202
  if e["name"] in seen:
191
203
  continue
192
204
  seen[e["name"]] = {"name": e["name"], "kind": e["kind"],
193
- "description": e["description"], "plugin": name or "unknown"}
205
+ "description": e["description"], "plugin": name or "unknown",
206
+ "writtenBy": e["writtenBy"]}
194
207
  order.append(e["name"])
195
208
  return [seen[n] for n in order]
196
209
 
@@ -355,9 +368,10 @@ def merge(project_dir, account_dir):
355
368
  lines = text.split("\n")
356
369
  lines[start + 1:end] = new_entries
357
370
  text = "\n".join(lines)
358
- # Regenerate both descriptive regions wholesale (idempotent).
371
+ # Regenerate every generated region wholesale (idempotent).
359
372
  text = _strip_region(text, MARK_START, MARK_END)
360
373
  text = _strip_region(text, ONT_START, ONT_END)
374
+ text = _strip_region(text, DECL_START, DECL_END)
361
375
  if owned:
362
376
  region = [MARK_START, "## Plugin-owned top-level entries", "",
363
377
  "A plugin declares the account-root entries its feature owns. A",
@@ -393,6 +407,28 @@ def merge(project_dir, account_dir):
393
407
  f"(owned by `{b['owner']}`), not a root bucket.")
394
408
  region.append(ONT_END)
395
409
  text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
410
+ # The declared-file set, machine-readable and separate from the
411
+ # allowed-top-level fence. That fence is a layout list — it says a name may
412
+ # exist at the account root — and the write guard reads it as its allow-list,
413
+ # so a name unioned into it becomes agent-writable. This fence carries the
414
+ # other half the guard needs: which of those names is written whole by
415
+ # platform code, and which plugin to name when it blocks the write. An
416
+ # agent-written declaration is excluded: its skill authors it with Write, so
417
+ # denying it would block the only path that creates it.
418
+ declared = [e for e in owned if e["kind"] == "file" and e["writtenBy"] != "agent"]
419
+ if declared:
420
+ region = [DECL_START, "## Declared files (written by platform code, owned by a plugin)", "",
421
+ "Each file below is written whole by platform code and owned by the",
422
+ "plugin named beside it. It is control-plane state, not operator data:",
423
+ "never hand-edit one, because the next platform write overwrites the",
424
+ "edit. The write guard reads this block and blocks a write whose first",
425
+ "path segment is one of these names.", "",
426
+ "```declared-files"]
427
+ for e in declared:
428
+ region.append(f"{e['name']}\t{e['plugin']}")
429
+ region.append("```")
430
+ region.append(DECL_END)
431
+ text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
396
432
  with open(schema_path, "w") as f:
397
433
  f.write(text)
398
434
  root_dirs = [b["dir"] for b in domain if _is_root(b["owner"])]