@rubytech/create-sitedesk-code 0.1.491 → 0.1.493

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 (92) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +46 -0
  3. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  4. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.d.ts +2 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.d.ts.map +1 -0
  6. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.js +135 -0
  7. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.js.map +1 -0
  8. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +7 -1
  9. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.js +26 -14
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/pages-output-dir.d.ts +19 -0
  13. package/payload/platform/lib/storage-broker/dist/pages-output-dir.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/pages-output-dir.js +185 -0
  15. package/payload/platform/lib/storage-broker/dist/pages-output-dir.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +65 -0
  17. package/payload/platform/lib/storage-broker/src/__tests__/pages-output-dir.test.ts +167 -0
  18. package/payload/platform/lib/storage-broker/src/cf-exec.ts +40 -16
  19. package/payload/platform/lib/storage-broker/src/pages-output-dir.ts +194 -0
  20. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  21. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
  22. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
  23. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
  24. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +13 -1
  25. package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
  26. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
  29. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +8 -4
  30. package/payload/platform/plugins/cloudflare/references/hosting-sites.md +1 -1
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +4 -4
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
  34. package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
  35. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
  37. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
  39. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  41. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
  43. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
  45. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
  46. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
  47. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
  48. package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
  49. package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
  50. package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
  51. package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
  52. package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
  53. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
  54. package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
  55. package/payload/platform/templates/specialists/agents/data-manager.md +1 -0
  56. package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
  57. package/payload/platform/templates/specialists/agents/librarian.md +1 -0
  58. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
  59. package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
  60. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
  61. package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
  62. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
  63. package/payload/premium-plugins/sitedesk/agents/sitedesk--media-producer.md +1 -0
  64. package/payload/premium-plugins/sitedesk/agents/sitedesk--payroll-clerk.md +1 -0
  65. package/payload/premium-plugins/sitedesk/agents/sitedesk--quoter.md +1 -0
  66. package/payload/premium-plugins/sitedesk/agents/sitedesk--valuer.md +1 -0
  67. package/payload/premium-plugins/sitedesk/agents/worker-checkin/IDENTITY.md +2 -0
  68. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/PLUGIN.md +1 -1
  69. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.d.ts +39 -0
  70. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.d.ts.map +1 -1
  71. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.js +86 -2
  72. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.js.map +1 -1
  73. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.d.ts +1 -1
  74. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.d.ts.map +1 -1
  75. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.js +27 -4
  76. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.js.map +1 -1
  77. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/gates.test.ts +81 -1
  78. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/render-run.test.ts +98 -0
  79. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/gates.ts +86 -2
  80. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/render-run.ts +36 -5
  81. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md +41 -12
  82. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/verification.md +51 -0
  83. package/payload/server/{chunk-KZFE3MOY.js → chunk-RPUTKSAJ.js} +134 -16
  84. package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
  85. package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
  86. package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
  87. package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
  88. package/payload/server/public/chat.html +2 -2
  89. package/payload/server/public/operator.html +2 -2
  90. package/payload/server/public/public.html +2 -2
  91. package/payload/server/server.js +40 -31
  92. package/payload/server/{src-UYDFMWF4.js → src-JN6PIAJN.js} +1 -1
@@ -1,5 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { gateReconcile, gateMarginLeak, gateSignLink } from "../gates.js";
2
+ import {
3
+ gateReconcile,
4
+ gateMarginLeak,
5
+ gateSignLink,
6
+ gateHeaderConformance,
7
+ gateTermsWeight,
8
+ gateReferenceConsistency,
9
+ } from "../gates.js";
3
10
 
4
11
  describe("gateReconcile — displayed total must equal the engine total", () => {
5
12
  it("passes when data-quote-total matches", () => {
@@ -62,3 +69,76 @@ describe("gateSignLink — client quote's acceptance anchor must be absolute htt
62
69
  expect(gateSignLink('<a data-esign-accept href="/sign">Sign</a>')).toEqual({ pass: false, href: "/sign", reason: "relative" });
63
70
  });
64
71
  });
72
+
73
+ describe("gateHeaderConformance — rendered header must be the declared canonical template verbatim", () => {
74
+ const TEMPLATE = '<div data-doc-header><img src="data:image/png;base64,AAAAlogoBBBB"><span>Acme Ltd</span></div>';
75
+ it("skips when the account declares no header template (template null)", () => {
76
+ expect(gateHeaderConformance("<div data-doc-header>anything</div>", null)).toEqual({ pass: true, skipped: true });
77
+ });
78
+ it("skips an empty or whitespace-only template rather than passing everything via includes(\"\")", () => {
79
+ expect(gateHeaderConformance("<div data-doc-header>anything</div>", " \n ")).toEqual({ pass: true, skipped: true });
80
+ });
81
+ it("passes when the rendered quote embeds the template verbatim (whitespace-tolerant)", () => {
82
+ const rendered = `<body>\n <div data-doc-header>\n <img src="data:image/png;base64,AAAAlogoBBBB">\n <span>Acme Ltd</span>\n </div>\n</body>`;
83
+ expect(gateHeaderConformance(rendered, TEMPLATE)).toEqual({ pass: true });
84
+ });
85
+ it("fails closed when the render marked no header block", () => {
86
+ expect(gateHeaderConformance("<body><h1>Quote</h1></body>", TEMPLATE)).toEqual({ pass: false, reason: "no-header-marker" });
87
+ });
88
+ it("fails a rebuilt approximation whose logo data differs", () => {
89
+ const rebuilt = '<div data-doc-header><img src="data:image/png;base64,DIFFERENTlogo"><span>Acme Ltd</span></div>';
90
+ expect(gateHeaderConformance(rebuilt, TEMPLATE)).toEqual({ pass: false, reason: "mismatch" });
91
+ });
92
+ it("fails a rebuilt approximation whose element structure differs", () => {
93
+ const rebuilt = '<div data-doc-header><h2>Acme Ltd</h2></div>';
94
+ expect(gateHeaderConformance(rebuilt, TEMPLATE)).toEqual({ pass: false, reason: "mismatch" });
95
+ });
96
+ });
97
+
98
+ describe("gateTermsWeight — the terms section must carry no above-normal weight", () => {
99
+ it("skips when the quote marks no terms section", () => {
100
+ expect(gateTermsWeight("<body><h1>Quote</h1></body>")).toEqual({ pass: true, skipped: true });
101
+ });
102
+ it("passes a terms section in normal weight", () => {
103
+ expect(gateTermsWeight('<section data-doc-terms><p>Payment due in 30 days.</p></section>')).toEqual({ pass: true });
104
+ });
105
+ it("fails a <strong> inside the terms section", () => {
106
+ expect(gateTermsWeight('<section data-doc-terms><p>Payment <strong>due now</strong>.</p></section>')).toEqual({ pass: false, reason: "bold-in-terms" });
107
+ });
108
+ it("fails a <b> inside the terms section", () => {
109
+ expect(gateTermsWeight('<section data-doc-terms><p><b>Note</b></p></section>')).toEqual({ pass: false, reason: "bold-in-terms" });
110
+ });
111
+ it("fails an inline font-weight:bold inside the terms section", () => {
112
+ expect(gateTermsWeight('<section data-doc-terms><p style="font-weight: bold">x</p></section>')).toEqual({ pass: false, reason: "bold-in-terms" });
113
+ });
114
+ it("fails an inline numeric font-weight:700 inside the terms section", () => {
115
+ expect(gateTermsWeight('<section data-doc-terms><p style="font-weight:700">x</p></section>')).toEqual({ pass: false, reason: "bold-in-terms" });
116
+ });
117
+ it("ignores bold outside the terms section", () => {
118
+ expect(gateTermsWeight('<h1><b>ACME QUOTE</b></h1><section data-doc-terms><p>normal terms</p></section>')).toEqual({ pass: true });
119
+ });
120
+ it("catches bold after a nested same-name element (a <div data-doc-terms> nesting a <div>)", () => {
121
+ // depth-balanced extraction: a non-greedy match would stop at the first
122
+ // </div> and miss the <strong> that follows the nested container.
123
+ const html = '<div data-doc-terms><div class="logo"></div><strong>NET 7 DAYS</strong></div>';
124
+ expect(gateTermsWeight(html)).toEqual({ pass: false, reason: "bold-in-terms" });
125
+ });
126
+ it("does not read bold from a sibling element after the terms container closes", () => {
127
+ const html = '<div data-doc-terms><p>normal terms</p></div><footer><b>Acme</b></footer>';
128
+ expect(gateTermsWeight(html)).toEqual({ pass: true });
129
+ });
130
+ });
131
+
132
+ describe("gateReferenceConsistency — one document reference across every marked place", () => {
133
+ it("passes when every data-doc-ref carries the same string", () => {
134
+ const html = '<header data-doc-ref="1848">…</header><footer data-doc-ref="1848">…</footer><div data-doc-ref="1848">accept</div>';
135
+ expect(gateReferenceConsistency(html)).toEqual({ pass: true, ref: "1848" });
136
+ });
137
+ it("skips when the quote marks no document reference (opt-in like header and terms)", () => {
138
+ expect(gateReferenceConsistency("<body><h1>Quote</h1></body>")).toEqual({ pass: true, skipped: true });
139
+ });
140
+ it("fails when a second variant appears (the 1848/1 class)", () => {
141
+ const html = '<header data-doc-ref="1848">…</header><div data-doc-ref="1848/1">accept</div>';
142
+ expect(gateReferenceConsistency(html)).toEqual({ pass: false, reason: "inconsistent", refs: ["1848", "1848/1"] });
143
+ });
144
+ });
@@ -60,6 +60,37 @@ process.stdin.on("end", () => {
60
60
  });
61
61
  `;
62
62
 
63
+ // A no-signer render whose quote carries an above-normal weight inside the
64
+ // marked standing-terms section → terms-weight, fail-closed.
65
+ const BOLD_TERMS = `
66
+ let input = "";
67
+ process.stdin.on("data", (d) => (input += d));
68
+ process.stdin.on("end", () => {
69
+ const { figures } = JSON.parse(input);
70
+ const t = figures.total;
71
+ process.stdout.write(JSON.stringify({
72
+ internal: '<html><body><td data-internal-only>cost 900</td><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
73
+ breakdown: '<html><body><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
74
+ quote: '<html><body><p data-quote-total="' + t + '">Total ' + t + '</p><section data-doc-terms><p><strong>NET 7 DAYS</strong></p></section></body></html>',
75
+ }));
76
+ });
77
+ `;
78
+ // A no-signer render whose quote carries two distinct document references (the
79
+ // 1848/1 class) → ref-inconsistent, fail-closed.
80
+ const REF_BAD = `
81
+ let input = "";
82
+ process.stdin.on("data", (d) => (input += d));
83
+ process.stdin.on("end", () => {
84
+ const { figures } = JSON.parse(input);
85
+ const t = figures.total;
86
+ process.stdout.write(JSON.stringify({
87
+ internal: '<html><body><td data-internal-only>cost 900</td><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
88
+ breakdown: '<html><body><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
89
+ quote: '<html><body><header data-doc-ref="1848">ref</header><p data-quote-total="' + t + '">Total ' + t + '</p><div data-doc-ref="1848/1">accept</div></body></html>',
90
+ }));
91
+ });
92
+ `;
93
+
63
94
  let dir: string;
64
95
  const figures = { total: 1250, header: { phone: "44700900000" }, lines: [] };
65
96
  const signUrl = "https://q.pages.dev/sign";
@@ -75,6 +106,8 @@ beforeAll(() => {
75
106
  writeFileSync(join(dir, "quoting", "render-subpenny.mjs"), SUBPENNY);
76
107
  writeFileSync(join(dir, "quoting", "render-throw.mjs"), THROW);
77
108
  writeFileSync(join(dir, "quoting", "render-nosigner.mjs"), NOSIGNER);
109
+ writeFileSync(join(dir, "quoting", "render-boldterms.mjs"), BOLD_TERMS);
110
+ writeFileSync(join(dir, "quoting", "render-refbad.mjs"), REF_BAD);
78
111
  });
79
112
  afterAll(() => rmSync(dir, { recursive: true, force: true }));
80
113
  const rs = (n: string) => join(dir, "quoting", n);
@@ -170,6 +203,71 @@ describe("runRender — a failing gate is fail-closed and atomic (no doc filed)"
170
203
  });
171
204
  });
172
205
 
206
+ describe("runRender — document-conformance gates over the client quote (fail-closed)", () => {
207
+ it("terms-weight when the quote's standing-terms section carries bold", async () => {
208
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-boldterms.mjs"), figures, jobId: "job-bold" });
209
+ expect(r).toMatchObject({ ok: false, reason: "terms-weight" });
210
+ expect(existsSync(join(dir, "memory/users/44700900000/documents/quote-job-bold.html"))).toBe(false);
211
+ expect(existsSync(receiptPath(dir, "job-bold"))).toBe(false);
212
+ });
213
+ it("ref-inconsistent when the quote carries a second document-reference variant (the 1848/1 class)", async () => {
214
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-refbad.mjs"), figures, jobId: "job-ref" });
215
+ expect(r).toMatchObject({ ok: false, reason: "ref-inconsistent" });
216
+ if (r.ok) return;
217
+ expect(r.detail).toMatch(/1848.*1848\/1/);
218
+ expect(existsSync(receiptPath(dir, "job-ref"))).toBe(false);
219
+ });
220
+ it("files normally when the quote carries no header template, no terms marker, and one consistent reference (all opt-in gates satisfied or skipped)", async () => {
221
+ // render-nosigner carries none of the new markers → header/terms skip, ref
222
+ // skips; the render still files, proving the new gates do not break an
223
+ // existing render script that has not adopted the markers.
224
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-nosigner.mjs"), figures, jobId: "job-optin-plain" });
225
+ expect(r.ok).toBe(true);
226
+ expect(existsSync(receiptPath(dir, "job-optin-plain"))).toBe(true);
227
+ });
228
+ });
229
+
230
+ describe("runRender — header conformance against a declared canonical template", () => {
231
+ let hdir: string;
232
+ const HEADER_TPL = '<div data-doc-header><img src="data:image/png;base64,AAAAlogoBBBB"><span>Acme Ltd</span></div>';
233
+ const renderEmitting = (headerBlock: string) => `
234
+ let input = "";
235
+ process.stdin.on("data", (d) => (input += d));
236
+ process.stdin.on("end", () => {
237
+ const { figures } = JSON.parse(input);
238
+ const t = figures.total;
239
+ const header = ${JSON.stringify(headerBlock)};
240
+ process.stdout.write(JSON.stringify({
241
+ internal: '<html><body><td data-internal-only>c</td><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
242
+ breakdown: '<html><body><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
243
+ quote: '<html><body>' + header + '<p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
244
+ }));
245
+ });
246
+ `;
247
+ beforeAll(() => {
248
+ hdir = mkdtempSync(join(tmpdir(), "sd-render-hdr-"));
249
+ mkdirSync(join(hdir, "quoting", "templates"), { recursive: true });
250
+ writeFileSync(join(hdir, "quoting", "templates", "header.html"), HEADER_TPL);
251
+ // conforming render embeds the declared template verbatim (whitespace
252
+ // tolerance is covered by the gates unit test)
253
+ writeFileSync(join(hdir, "quoting", "render-ok.mjs"), renderEmitting(HEADER_TPL));
254
+ // rebuilt approximation: structure differs, logo data absent
255
+ writeFileSync(join(hdir, "quoting", "render-bad.mjs"), renderEmitting('<div data-doc-header><h2>Acme Ltd</h2></div>'));
256
+ });
257
+ afterAll(() => rmSync(hdir, { recursive: true, force: true }));
258
+
259
+ it("passes when the rendered header embeds the declared template verbatim", async () => {
260
+ const r = await runRender({ accountDir: hdir, renderScriptPath: join(hdir, "quoting", "render-ok.mjs"), figures, jobId: "job-hdr-ok" });
261
+ expect(r.ok).toBe(true);
262
+ expect(existsSync(receiptPath(hdir, "job-hdr-ok"))).toBe(true);
263
+ });
264
+ it("header-mismatch when the header is a rebuilt approximation, not the declared template", async () => {
265
+ const r = await runRender({ accountDir: hdir, renderScriptPath: join(hdir, "quoting", "render-bad.mjs"), figures, jobId: "job-hdr-bad" });
266
+ expect(r).toMatchObject({ ok: false, reason: "header-mismatch" });
267
+ expect(existsSync(receiptPath(hdir, "job-hdr-bad"))).toBe(false);
268
+ });
269
+ });
270
+
173
271
  describe("runRender — script faults", () => {
174
272
  it("render-absent when the render script is missing", async () => {
175
273
  const r = await runRender({ accountDir: dir, renderScriptPath: rs("nope.mjs"), figures, signUrl, jobId });
@@ -1,13 +1,19 @@
1
- // The three deterministic, fail-closed gates quote-render enforces on the HTML
2
- // the operator's render script emits. They are structural checks over stable
1
+ // The deterministic, fail-closed gates quote-render enforces on the HTML the
2
+ // operator's render script emits. They are structural checks over stable
3
3
  // attributes the render script marks — not business content:
4
4
  // data-quote-total the displayed grand total (reconcile against the engine)
5
5
  // data-internal-only cost/margin-bearing figures (must be absent from client docs)
6
6
  // data-esign-accept the client quote's Sign-Online anchor (absolute https only)
7
+ // data-doc-header the header block (must be the declared canonical template verbatim)
8
+ // data-doc-terms the standing-terms section (must carry no above-normal weight)
9
+ // data-doc-ref every place the document reference appears (must all agree)
7
10
 
8
11
  export interface ReconcileVerdict { pass: boolean; shown?: number }
9
12
  export interface LeakVerdict { pass: boolean }
10
13
  export interface SignLinkVerdict { pass: boolean; href?: string; reason?: "no-anchor" | "empty" | "fragment" | "non-https" | "relative" }
14
+ export interface HeaderVerdict { pass: boolean; skipped?: boolean; reason?: "no-header-marker" | "mismatch" }
15
+ export interface TermsVerdict { pass: boolean; skipped?: boolean; reason?: "bold-in-terms" }
16
+ export interface RefVerdict { pass: boolean; skipped?: boolean; ref?: string; reason?: "inconsistent"; refs?: string[] }
11
17
 
12
18
  /** The document's displayed grand total (data-quote-total) must equal the
13
19
  * engine total at penny precision — money is penny-precise, so a sub-penny
@@ -43,3 +49,81 @@ export function gateSignLink(html: string): SignLinkVerdict {
43
49
  if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return { pass: false, href, reason: "non-https" };
44
50
  return { pass: false, href, reason: "relative" };
45
51
  }
52
+
53
+ /** Whitespace-normalise for a structure-and-content comparison that tolerates
54
+ * the indentation a template engine adds but nothing else: whitespace between
55
+ * tags is insignificant (a compact template vs a pretty-printed render), and
56
+ * runs of whitespace inside text collapse to one space. */
57
+ function normaliseHtml(s: string): string {
58
+ return s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
59
+ }
60
+
61
+ /** The rendered quote's header must be the operator's declared canonical header
62
+ * (`quoting/templates/header.html`) verbatim — its element structure and its
63
+ * embedded logo data, not a rebuilt approximation. The template is passed in
64
+ * (null when the account declares none). No template → skip. Template present
65
+ * but the render marked no header block → fail closed (an unverifiable header
66
+ * is not shipped). Otherwise the template must appear verbatim (whitespace
67
+ * tolerant) in the rendered document; a rebuilt header — different structure or
68
+ * altered/absent logo data — is not a substring and fails. */
69
+ export function gateHeaderConformance(html: string, template: string | null): HeaderVerdict {
70
+ // A null or empty/whitespace-only template is "no canonical header declared":
71
+ // skip, rather than let `includes("")` report a pass that enforced nothing.
72
+ if (template === null || normaliseHtml(template) === "") return { pass: true, skipped: true };
73
+ if (!/\bdata-doc-header\b/i.test(html)) return { pass: false, reason: "no-header-marker" };
74
+ return normaliseHtml(html).includes(normaliseHtml(template)) ? { pass: true } : { pass: false, reason: "mismatch" };
75
+ }
76
+
77
+ /** Extract the inner content of the single element the render marks with
78
+ * `attr`, matched by **balanced tag depth** so a nested element of the same
79
+ * name does not truncate the capture. A non-greedy regex would stop at the
80
+ * first same-name close tag, letting a violation that sits after a nested
81
+ * `<div>`/`<section>` escape a fail-closed gate; depth-balancing captures the
82
+ * container's true extent. null when the marker is absent; on unbalanced HTML
83
+ * (a render-script defect) it captures to end, the fail-closed direction. */
84
+ function markedInner(html: string, attr: string): string | null {
85
+ const open = new RegExp(`<(\\w+)[^>]*\\b${attr}\\b[^>]*>`, "i").exec(html);
86
+ if (!open) return null;
87
+ const tag = open[1];
88
+ const start = open.index + open[0].length;
89
+ const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
90
+ tagRe.lastIndex = start;
91
+ let depth = 1;
92
+ let m: RegExpExecArray | null;
93
+ while ((m = tagRe.exec(html)) !== null) {
94
+ if (m[1] === "/") {
95
+ if (--depth === 0) return html.slice(start, m.index);
96
+ } else {
97
+ depth++;
98
+ }
99
+ }
100
+ return html.slice(start);
101
+ }
102
+
103
+ /** The standing-terms section (marked `data-doc-terms`) must carry no
104
+ * above-normal font weight: no `<b>`/`<strong>` element and no inline
105
+ * `font-weight` of bold, bolder, or a numeric 500–900. No terms marker → skip
106
+ * (a quote may carry no terms). The check is scoped to the marked section, so
107
+ * a bold heading elsewhere in the document does not fail it. */
108
+ export function gateTermsWeight(html: string): TermsVerdict {
109
+ const terms = markedInner(html, "data-doc-terms");
110
+ if (terms === null) return { pass: true, skipped: true };
111
+ const bold = /<b[\s>]/i.test(terms)
112
+ || /<strong[\s>]/i.test(terms)
113
+ || /font-weight\s*:\s*(bold|bolder|[5-9]00)\b/i.test(terms);
114
+ return bold ? { pass: false, reason: "bold-in-terms" } : { pass: true };
115
+ }
116
+
117
+ /** Every place the render marks a document reference (`data-doc-ref="<ref>"`)
118
+ * must carry the same string. The marker is opt-in like the header and terms
119
+ * markers: zero markers → skip (an existing render script that has not adopted
120
+ * it is not broken). One distinct value → pass. A second distinct value — the
121
+ * `1848/1` class, where the header says `1848` and the acceptance block says
122
+ * `1848/1` — fails and reports the distinct values in first-seen order. */
123
+ export function gateReferenceConsistency(html: string): RefVerdict {
124
+ const refs = [...html.matchAll(/\bdata-doc-ref="([^"]*)"/gi)].map((m) => m[1]);
125
+ if (refs.length === 0) return { pass: true, skipped: true };
126
+ const distinct = [...new Set(refs)];
127
+ if (distinct.length === 1) return { pass: true, ref: distinct[0] };
128
+ return { pass: false, reason: "inconsistent", refs: distinct };
129
+ }
@@ -8,12 +8,28 @@
8
8
  // cause-naming fault is returned. "Fail loudly and stop", never a partial issue.
9
9
 
10
10
  import { spawn } from "node:child_process";
11
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
- import { gateReconcile, gateMarginLeak, gateSignLink, type SignLinkVerdict } from "./gates.js";
13
+ import {
14
+ gateReconcile,
15
+ gateMarginLeak,
16
+ gateSignLink,
17
+ gateHeaderConformance,
18
+ gateTermsWeight,
19
+ gateReferenceConsistency,
20
+ type SignLinkVerdict,
21
+ } from "./gates.js";
14
22
  import { writeRenderReceipt } from "./render-receipt.js";
15
23
 
16
- export type RenderFault = "render-absent" | "render-error" | "reconcile-fail" | "margin-leak" | "sign-link";
24
+ export type RenderFault =
25
+ | "render-absent"
26
+ | "render-error"
27
+ | "reconcile-fail"
28
+ | "margin-leak"
29
+ | "sign-link"
30
+ | "header-mismatch"
31
+ | "terms-weight"
32
+ | "ref-inconsistent";
17
33
 
18
34
  export interface RunRenderArgs {
19
35
  accountDir: string;
@@ -114,9 +130,21 @@ export async function runRender(a: RunRenderArgs): Promise<RenderResult> {
114
130
  // The verdict is reported only when a signer was requested; null otherwise.
115
131
  const signLink: SignLinkVerdict | null = esignRequested ? signVerdict : null;
116
132
 
133
+ // Document-conformance gates over the issued client quote. The header must be
134
+ // the account's declared canonical template verbatim (a fixed-path opt-in:
135
+ // quoting/templates/header.html — absent → the header check is skipped); the
136
+ // standing-terms section must carry no above-normal weight; and every marked
137
+ // document reference must agree. Each is fail-closed, so a violation writes no
138
+ // receipt and the browser-pdf-save gate then refuses the print.
139
+ const headerTemplatePath = join(a.accountDir, "quoting", "templates", "header.html");
140
+ const headerTemplate = existsSync(headerTemplatePath) ? readFileSync(headerTemplatePath, "utf8") : null;
141
+ const header = gateHeaderConformance(html.quote, headerTemplate);
142
+ const terms = gateTermsWeight(html.quote);
143
+ const ref = gateReferenceConsistency(html.quote);
144
+
117
145
  const internalOk = recInternal.pass;
118
146
  const breakdownOk = recBreakdown.pass && leakBreakdown.pass;
119
- const quoteOk = recQuote.pass && leakQuote.pass && signOk;
147
+ const quoteOk = recQuote.pass && leakQuote.pass && signOk && header.pass && terms.pass && ref.pass;
120
148
 
121
149
  if (!internalOk || !breakdownOk || !quoteOk) {
122
150
  // Reason names the first failing gate, in document then gate order.
@@ -127,7 +155,10 @@ export async function runRender(a: RunRenderArgs): Promise<RenderResult> {
127
155
  else if (!leakBreakdown.pass) { reason = "margin-leak"; detail = "breakdown carries data-internal-only"; }
128
156
  else if (!recQuote.pass) { reason = "reconcile-fail"; detail = `quote total ${recQuote.shown} != engine ${engineTotal}`; }
129
157
  else if (!leakQuote.pass) { reason = "margin-leak"; detail = "quote carries data-internal-only"; }
130
- else { reason = "sign-link"; detail = signDetail; }
158
+ else if (!signOk) { reason = "sign-link"; detail = signDetail; }
159
+ else if (!header.pass) { reason = "header-mismatch"; detail = header.reason === "no-header-marker" ? "quote marked no header block to verify against the declared canonical template" : "quote header does not match the declared canonical template"; }
160
+ else if (!terms.pass) { reason = "terms-weight"; detail = "quote terms section carries above-normal font weight (bold/strong/font-weight >= 500)"; }
161
+ else { reason = "ref-inconsistent"; detail = `quote document references disagree: ${(ref.refs ?? []).join(", ")}`; }
131
162
  return { ok: false, reason, detail };
132
163
  }
133
164
 
@@ -42,7 +42,7 @@ layout, headings, narrative, charts, page size, contact and terms as their own d
42
42
  templates nothing and carries no brand. Generating and maintaining that render script is the build
43
43
  half of this skill, exactly as generating the pricing engine is the build half of the compute core.
44
44
 
45
- The render script marks three stable attributes so the tool gates the output deterministically,
45
+ The render script marks stable attributes so the tool gates the output deterministically,
46
46
  whatever the operator's brand or language:
47
47
 
48
48
  - **`data-quote-total`** on each document's displayed grand total, carrying the numeric total, so the
@@ -53,19 +53,41 @@ whatever the operator's brand or language:
53
53
  present **only when the operator opted the quote into online acceptance** — so the tool asserts the
54
54
  acceptance link is live.
55
55
 
56
- `quote-render` enforces three fail-closed gates before it files anything: **reconcile** (each
57
- document's `data-quote-total` equals the engine total), **margin-leak** (no `data-internal-only` in the
58
- client breakdown or client quote), and **link-liveness** (the client quote's `data-esign-accept` href
59
- is an absolute `https://` URL). Link-liveness runs **only on the opt-in path** when a `signUrl` was
60
- passed; on the default, signer-less path the quote must carry no acceptance anchor, and a stray one
61
- fails closed. If any gate fails the tool files nothing and returns a cause-naming fault
62
- (`reconcile-fail`, `margin-leak`, `sign-link`, `render-absent`, `render-error`). On success it files
63
- each document to its scope (below) and returns the three HTML paths plus the gate verdicts.
56
+ Three further attributes let the tool enforce the operator's own document-conformance standards on the
57
+ client quote (see `references/verification.md`, "Verifying the issued document conforms"):
58
+
59
+ - **`data-doc-header`** on the header block, so the tool can hold it to the account's declared canonical
60
+ header template at `quoting/templates/header.html` (verbatim, structure and embedded logo data). The
61
+ template is a fixed-path opt-in: when the account declares none the header check is skipped.
62
+ - **`data-doc-terms`** on the standing-terms section, so the tool asserts it carries no above-normal
63
+ weight (no `<b>`/`<strong>`, no inline `font-weight` of bold, bolder, or 500 to 900). No terms marker,
64
+ no check.
65
+ - **`data-doc-ref="<ref>"`** on every place the document reference appears (header, footers, title,
66
+ acceptance section), all carrying the same string, so a second variant (the `1848/1` class) fails. No
67
+ reference marker, no check, so an existing render script is not broken by adopting the marker later.
68
+
69
+ These conformance markers are opt-in, exactly like `data-esign-accept`: the gate for a marker runs only
70
+ once the render script emits it (and, for the header, only once the account drops its template file).
71
+
72
+ `quote-render` enforces its fail-closed gates before it files anything: **reconcile** (each document's
73
+ `data-quote-total` equals the engine total), **margin-leak** (no `data-internal-only` in the client
74
+ breakdown or client quote), **link-liveness** (the client quote's `data-esign-accept` href is an
75
+ absolute `https://` URL), and the client-quote **document-conformance** gates above (header,
76
+ terms-weight, reference-consistency, each opt-in on its marker). Link-liveness runs **only on the
77
+ opt-in path** — when a `signUrl` was passed; on the default, signer-less path the quote must carry no
78
+ acceptance anchor, and a stray one fails closed. If any gate fails the tool files nothing and returns a
79
+ cause-naming fault (`reconcile-fail`, `margin-leak`, `sign-link`, `header-mismatch`, `terms-weight`,
80
+ `ref-inconsistent`, `render-absent`, `render-error`). On success it files each document to its scope
81
+ (below) and returns the three HTML paths plus the gate verdicts.
64
82
 
65
83
  The tool produces **HTML, not PDF**. Print each returned path to PDF with the `browser` plugin as the
66
84
  final step: `browser-navigate` to its `file://` URL, then `browser-pdf-save` to a `.pdf` alongside it,
67
85
  confirming a non-zero byte count. A Workflow chains `quote-engine-run`, then `quote-render`, then one
68
- `browser` `browser-pdf-save` step per document.
86
+ `browser` `browser-pdf-save` step per document. A **PostToolUse gate on `browser-pdf-save`** then holds
87
+ the printed client-quote PDF to its two PDF-only conformance checks: its `/URI` count is at least the
88
+ source HTML's absolute-link count, and every embedded `https://` link answers 200. A failure blocks the
89
+ print with `[quote-render] op=bypass reason=pdf-link-conformance`, so a link-stripped or dead-link PDF
90
+ is not sent.
69
91
 
70
92
  ## The three documents
71
93
 
@@ -170,9 +192,9 @@ For each returned path, print it the same way as every other business-assistant
170
192
  3. confirm a non-zero byte count from the `Saved PDF to <path> (<bytes>)` line before treating the PDF
171
193
  as done.
172
194
 
173
- ## The tool enforces the reconciliation and link-liveness gates
195
+ ## The tool enforces the reconciliation, link-liveness and document-conformance gates
174
196
 
175
- `quote-render` runs three fail-closed gates over the rendered HTML before it files any document, so
197
+ `quote-render` runs its fail-closed gates over the rendered HTML before it files any document, so
176
198
  these checks are enforced deterministically at render time, not as a separate hand-run step:
177
199
 
178
200
  - **reconciliation.** Each document's `data-quote-total` must equal the engine total. A mismatch files
@@ -187,6 +209,13 @@ these checks are enforced deterministically at render time, not as a separate ha
187
209
  other signal. On the default, signer-less path (no `signUrl`) the quote must carry **no** acceptance
188
210
  anchor at all; a stray anchor also returns `sign-link` (a signer link with no signing page is a dead
189
211
  button).
212
+ - **document conformance (opt-in per marker).** The client quote's header must be the account's
213
+ declared canonical template (`header-mismatch`), its terms section must carry no above-normal weight
214
+ (`terms-weight`), and every marked document reference must agree (`ref-inconsistent`). Each runs only
215
+ once the render marks its attribute (and, for the header, once the account declares its template), so
216
+ a render script that has not adopted a marker is not broken. `references/verification.md` owns why and
217
+ what each asserts. The two PDF-only conformance checks (link count kept, links live) run afterward in
218
+ the PostToolUse gate on `browser-pdf-save`, because they need the printed PDF.
190
219
 
191
220
  The tool emits one `[quote-render] op=render jobId=… docs=3 internal=… breakdown=… quote=…` line on
192
221
  success and `[quote-render] op=fault jobId=… reason=… detail=…` on any gate failure. On success it also
@@ -49,3 +49,54 @@ execute, a direct invocation that bypasses the persisted `quoting/engine.mjs`
49
49
  the quote the operator will actually issue. The tool records each verify as `[quote-engine] op=verify
50
50
  jobId=… worst=…`; absence of that line for a method claimed verified is the bypass this discipline
51
51
  exists to catch.
52
+
53
+ # Verifying the issued document conforms to the operator's standards
54
+
55
+ The section above accepts the **method**: that it reproduces the operator's past figures. This
56
+ section accepts the **document**: that the issued quote matches the operator's own presentation
57
+ standards. They are two distinct acceptance disciplines over two different things. A quote can
58
+ reproduce every figure to the penny and still go out with a hand-built header, bold buried in the
59
+ terms, or two different quote numbers on the same page. Those are mechanically checkable, so they are
60
+ checked deterministically rather than left to the eye.
61
+
62
+ ## The gate runs on the deterministic path, not as a side script
63
+
64
+ The conformance checks are enforced where the deterministic render path already runs, so there is no
65
+ second surface an operator can forget. The **HTML checks run inside `quote-render`** (the same tool
66
+ and the same fail-closed sequence as reconcile, margin-leak and link-liveness): a violation files no
67
+ document and writes no render receipt, and the `browser-pdf-save` gate then refuses to print a client
68
+ quote that has no receipt. The **PDF checks run in a PostToolUse gate on `browser-pdf-save`**, because
69
+ they need the printed PDF, which does not exist until that tool runs. A conformance violation is
70
+ therefore surfaced at render or print time, before the draft reaches the operator, not by the operator
71
+ noticing it in a delivered quote.
72
+
73
+ ## What each check asserts
74
+
75
+ Each check is structural, over a stable attribute the operator's render script marks (the same pattern
76
+ as `data-quote-total`), so the gate carries no brand and no business content.
77
+
78
+ - **Header is the declared canonical template.** When the account declares a canonical header at the
79
+ fixed path `quoting/templates/header.html`, the rendered quote's header block (marked
80
+ `data-doc-header`) must contain that template verbatim, its element structure and its embedded logo
81
+ data, not a rebuilt approximation. No declared template means the check is skipped.
82
+ - **The terms carry no above-normal weight.** Inside the standing-terms section (marked
83
+ `data-doc-terms`) there is no `<b>`, no `<strong>`, and no inline `font-weight` of bold, bolder, or a
84
+ numeric 500 to 900. No terms marker means the check is skipped.
85
+ - **One document reference, everywhere.** Every place the render marks the document reference
86
+ (`data-doc-ref="<ref>"`) carries the same string. A second variant, the `1848/1` class where the
87
+ header says `1848` and the acceptance block says `1848/1`, fails. No reference marker means the check
88
+ is skipped, so a render script that has not yet adopted the marker is not broken.
89
+ - **The PDF keeps every link and each link is live.** The printed PDF's `/URI` annotation count is at
90
+ least the source HTML's absolute-link count (a rasterised or flattened print that dropped links is
91
+ caught), and every embedded `https://` link answers 200 (a Sign-Online link that a pending redeploy
92
+ would 404 is caught before the PDF is sent).
93
+
94
+ The signature-asset check for order-class documents is not covered here, because this skill's render
95
+ contract issues quote documents, not orders; it is tracked separately.
96
+
97
+ ## What a conformance failure tells you
98
+
99
+ A failure names the check and the failing value on the tool's fault line (`[quote-render] op=fault
100
+ reason=header-mismatch|terms-weight|ref-inconsistent …`) or the PDF gate's bypass line
101
+ (`[quote-render] op=bypass reason=pdf-link-conformance …`). Fix the render script or the input so the
102
+ document conforms, then re-render. The threshold is never widened; the document is corrected.