@rubytech/create-sitedesk-code 0.1.510 → 0.1.512

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 (30) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/package.json +3 -0
  3. package/payload/platform/plugins/admin/skills/deck-pages/SKILL.md +2 -2
  4. package/payload/platform/plugins/admin/skills/skill-builder/SKILL.md +1 -1
  5. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +10 -0
  6. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +15 -34
  7. package/payload/platform/plugins/business-assistant/skills/e-sign/stamp.mjs +269 -0
  8. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +5 -5
  9. package/payload/platform/plugins/memory/skills/conversation-archive-enrich/SKILL.md +2 -2
  10. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +1 -1
  11. package/payload/platform/plugins/x-import/skills/x-import/SKILL.md +1 -1
  12. package/payload/platform/scripts/check-no-esm-require.mjs +6 -0
  13. package/payload/platform/scripts/logs-read.sh +117 -36
  14. package/payload/platform/scripts/logs-read.test.sh +234 -0
  15. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  16. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +13 -0
  17. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +7 -3
  19. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +17 -11
  21. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -1
  22. package/payload/server/maxy-edge.js +6 -2
  23. package/payload/server/public/assets/admin-6QN-k3zQ.js +1 -0
  24. package/payload/server/public/assets/admin-6QN-k3zQ.js.br +0 -0
  25. package/payload/server/public/assets/admin-6QN-k3zQ.js.gz +0 -0
  26. package/payload/server/public/index.html +1 -1
  27. package/payload/server/server.js +777 -349
  28. package/payload/server/public/assets/admin-DGr1HuPg.js +0 -1
  29. package/payload/server/public/assets/admin-DGr1HuPg.js.br +0 -0
  30. package/payload/server/public/assets/admin-DGr1HuPg.js.gz +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.510",
3
+ "version": "0.1.512",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -5,6 +5,9 @@
5
5
  "plugins/*/mcp",
6
6
  "services/*"
7
7
  ],
8
+ "dependencies": {
9
+ "pdf-lib": "^1.17.1"
10
+ },
8
11
  "scripts": {
9
12
  "build": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p services/claude-session-manager/tsconfig.json && tsc -p services/whatsapp-channel/tsconfig.json && tsc -p services/webchat-channel/tsconfig.json && tsc -p services/telegram-channel/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
13
  "build:lib": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json",
@@ -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,16 @@ 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.512)
13
+
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
18
+ ## 2026-07-26 (0.1.511)
19
+
20
+ - 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.
21
+
12
22
  ## 2026-07-26 (0.1.510)
13
23
 
14
24
  - The admin screens load faster: page assets are now sent pre-compressed, and the sessions list reads from disk without holding up everything else on the server.
@@ -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
+ }
@@ -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).
@@ -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)
@@ -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',