@rubytech/create-sitedesk-code 0.1.429 → 0.1.432

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 (144) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +69 -3
  3. package/payload/platform/plugins/docs/PLUGIN.md +1 -0
  4. package/payload/platform/plugins/docs/references/internals.md +2 -2
  5. package/payload/platform/plugins/docs/references/workflows.md +61 -0
  6. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.d.ts +8 -1
  7. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.d.ts.map +1 -1
  8. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.js +8 -1
  9. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.js.map +1 -1
  10. package/payload/platform/plugins/workflows/PLUGIN.md +29 -9
  11. package/payload/platform/plugins/workflows/mcp/dist/index.js +34 -0
  12. package/payload/platform/plugins/workflows/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts +2 -2
  14. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts.map +1 -1
  15. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.js.map +1 -1
  16. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts +25 -0
  17. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts.map +1 -1
  18. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js +14 -1
  19. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js.map +1 -1
  20. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts +25 -1
  21. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts.map +1 -1
  22. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js +79 -15
  23. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js.map +1 -1
  24. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts +2 -0
  25. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts.map +1 -1
  26. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js +4 -0
  27. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js.map +1 -1
  28. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts +3 -1
  29. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts.map +1 -1
  30. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js +8 -1
  31. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js.map +1 -1
  32. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +24 -3
  33. package/payload/platform/services/telegram-channel/dist/instructions.js +1 -1
  34. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  35. package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -5
  36. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  37. package/payload/platform/services/telegram-channel/dist/notification.js +9 -6
  38. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  39. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  40. package/payload/platform/services/telegram-channel/dist/server.js +15 -5
  41. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  42. package/payload/platform/services/webchat-channel/dist/instructions.js +1 -1
  43. package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
  44. package/payload/platform/services/webchat-channel/dist/notification.d.ts +12 -10
  45. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  46. package/payload/platform/services/webchat-channel/dist/notification.js +12 -11
  47. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  48. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  49. package/payload/platform/services/webchat-channel/dist/server.js +12 -1
  50. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  51. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +11 -6
  52. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  53. package/payload/platform/services/whatsapp-channel/dist/notification.js +11 -8
  54. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  55. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  56. package/payload/platform/services/whatsapp-channel/dist/server.js +57 -14
  57. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  58. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +26 -2
  59. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  60. package/payload/platform/services/whatsapp-channel/dist/targets.js +50 -2
  61. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  62. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.d.ts +8 -0
  63. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.d.ts.map +1 -0
  64. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.js +71 -0
  65. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.js.map +1 -0
  66. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/package.json +7 -0
  67. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/PLUGIN.md +39 -3
  68. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.d.ts +2 -0
  69. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.d.ts.map +1 -0
  70. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.js +32 -0
  71. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.js.map +1 -0
  72. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.d.ts +10 -0
  73. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.d.ts.map +1 -0
  74. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.js +21 -0
  75. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.js.map +1 -0
  76. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.d.ts +24 -0
  77. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.d.ts.map +1 -0
  78. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.js +59 -0
  79. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.js.map +1 -0
  80. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.d.ts +22 -0
  81. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.d.ts.map +1 -0
  82. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.js +40 -0
  83. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.js.map +1 -0
  84. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.d.ts +47 -0
  85. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.d.ts.map +1 -0
  86. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.js +60 -0
  87. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.js.map +1 -0
  88. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.d.ts +7 -0
  89. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.d.ts.map +1 -0
  90. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.js +9 -0
  91. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.js.map +1 -0
  92. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.d.ts +29 -0
  93. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.d.ts.map +1 -0
  94. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.js +113 -0
  95. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.js.map +1 -0
  96. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.d.ts +3 -0
  97. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.d.ts.map +1 -0
  98. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.js +48 -0
  99. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.js.map +1 -0
  100. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.d.ts +3 -0
  101. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.d.ts.map +1 -0
  102. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.js +50 -0
  103. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.js.map +1 -0
  104. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.d.ts +3 -0
  105. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.d.ts.map +1 -0
  106. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.js +42 -0
  107. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.js.map +1 -0
  108. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/package-lock.json +2684 -0
  109. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/package.json +21 -0
  110. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/index.ts +39 -0
  111. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/account.test.ts +49 -0
  112. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/engine-run.test.ts +105 -0
  113. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/gates.test.ts +51 -0
  114. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/geometry.test.ts +83 -0
  115. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/render-run.test.ts +111 -0
  116. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/account.ts +20 -0
  117. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/engine-run.ts +80 -0
  118. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/gates.ts +39 -0
  119. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/geometry.ts +93 -0
  120. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/log.ts +8 -0
  121. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/render-run.ts +125 -0
  122. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/tools/quote-engine-run.ts +66 -0
  123. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/tools/quote-render.ts +66 -0
  124. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/tools/takeoff-compute.ts +51 -0
  125. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/tsconfig.json +20 -0
  126. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-engine.md +24 -0
  127. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md +67 -51
  128. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/takeoff/SKILL.md +12 -0
  129. package/payload/server/public/assets/{AdminLoginScreens-C4AZT7cQ.js → AdminLoginScreens-BM45lL5G.js} +1 -1
  130. package/payload/server/public/assets/{admin-SaF1F4e-.js → admin-B8Eb9Wbj.js} +1 -1
  131. package/payload/server/public/assets/{audio-attachment-mime-BBW07eyG.js → audio-attachment-mime-Cx5d3Jls.js} +1 -1
  132. package/payload/server/public/assets/{chat-B9Qjt7C9.js → chat-LEEUAJOG.js} +1 -1
  133. package/payload/server/public/assets/data-CKojMQSz.js +1 -0
  134. package/payload/server/public/assets/{operator-CTJjI8te.js → operator-OwY_sWTH.js} +1 -1
  135. package/payload/server/public/assets/{page-CbI1wtef.js → page-B7nCws9K.js} +1 -1
  136. package/payload/server/public/assets/{page-JzxyFGle.js → page-C3L6-Ufk.js} +5 -5
  137. package/payload/server/public/assets/{public-DzeW3_eM.js → public-CSLXSSy5.js} +1 -1
  138. package/payload/server/public/chat.html +3 -3
  139. package/payload/server/public/data.html +3 -3
  140. package/payload/server/public/index.html +4 -4
  141. package/payload/server/public/operator.html +4 -4
  142. package/payload/server/public/public.html +3 -3
  143. package/payload/server/server.js +1647 -1470
  144. package/payload/server/public/assets/data-BKE6Sa-g.js +0 -1
@@ -0,0 +1,93 @@
1
+ // Deterministic takeoff geometry. Pure arithmetic over agent-supplied measured
2
+ // inputs — scale to a real dimension, a scaled area from two sides, and a count
3
+ // roll-up — plus a cross-check of a scaled quantity against a printed figure.
4
+ // Reading the drawing and eliciting the inputs is the takeoff skill's job, not
5
+ // this module's. No business content: it multiplies numbers, it prices nothing.
6
+
7
+ export type Basis = "printed" | "scaled" | "count" | "operator";
8
+
9
+ /** A drawing scale: a stated ratio (1:R → R), or a known-dimension calibration
10
+ * where `actual/measuredRef` is the factor. */
11
+ export type Scale = { ratio: number } | { measuredRef: number; actual: number };
12
+
13
+ export interface InLine {
14
+ element: string;
15
+ basis: Basis;
16
+ value?: number; // printed | operator
17
+ unit?: string;
18
+ measured?: number; // scaled
19
+ measuredB?: number; // scaled area — second side
20
+ kind?: "length" | "area"; // scaled
21
+ count?: number | number[]; // count
22
+ crossCheck?: { value: number; unit?: string; tolerance?: number };
23
+ }
24
+
25
+ export interface OutLine {
26
+ element: string;
27
+ quantity: number;
28
+ unit?: string;
29
+ basis: Basis;
30
+ crossCheck?: { printed: number; delta: number; disagree: boolean };
31
+ }
32
+
33
+ export interface TakeoffResult {
34
+ lines: OutLine[];
35
+ counts: { exact: number; scaled: number };
36
+ }
37
+
38
+ function scaleFactor(scale: Scale | undefined, element: string): number {
39
+ if (!scale) throw new Error(`takeoff: line "${element}" is scale-derived but no scale was supplied`);
40
+ const factor = "ratio" in scale ? scale.ratio : scale.actual / scale.measuredRef;
41
+ if (!Number.isFinite(factor)) {
42
+ throw new Error(`takeoff: line "${element}" has a degenerate scale (factor=${factor})`);
43
+ }
44
+ return factor;
45
+ }
46
+
47
+ function quantityFor(line: InLine, scale: Scale | undefined): number {
48
+ switch (line.basis) {
49
+ case "printed":
50
+ case "operator": {
51
+ if (typeof line.value !== "number") {
52
+ throw new Error(`takeoff: line "${line.element}" (${line.basis}) is missing its value`);
53
+ }
54
+ return line.value;
55
+ }
56
+ case "count": {
57
+ if (line.count === undefined) {
58
+ throw new Error(`takeoff: line "${line.element}" (count) is missing its count`);
59
+ }
60
+ return Array.isArray(line.count) ? line.count.reduce((a, b) => a + b, 0) : line.count;
61
+ }
62
+ case "scaled": {
63
+ if (typeof line.measured !== "number") {
64
+ throw new Error(`takeoff: line "${line.element}" (scaled) is missing its measured length`);
65
+ }
66
+ const factor = scaleFactor(scale, line.element);
67
+ if (line.kind === "area") {
68
+ if (typeof line.measuredB !== "number") {
69
+ throw new Error(`takeoff: line "${line.element}" (scaled area) is missing its second side`);
70
+ }
71
+ return line.measured * line.measuredB * factor * factor;
72
+ }
73
+ return line.measured * factor;
74
+ }
75
+ }
76
+ }
77
+
78
+ export function computeTakeoff(input: { scale?: Scale; lines: InLine[] }): TakeoffResult {
79
+ const lines: OutLine[] = input.lines.map((line) => {
80
+ const quantity = quantityFor(line, input.scale);
81
+ const out: OutLine = { element: line.element, quantity, unit: line.unit, basis: line.basis };
82
+ if (line.basis === "scaled" && line.crossCheck) {
83
+ const printed = line.crossCheck.value;
84
+ const delta = quantity - printed;
85
+ const tolerance = line.crossCheck.tolerance ?? 0;
86
+ out.crossCheck = { printed, delta, disagree: Math.abs(delta) > tolerance };
87
+ }
88
+ return out;
89
+ });
90
+
91
+ const scaled = lines.filter((l) => l.basis === "scaled").length;
92
+ return { lines, counts: { exact: lines.length - scaled, scaled } };
93
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Emit one structured observability line to stderr. Every tool invocation logs
3
+ * one line here, correlated by `jobId`; journald captures stderr, so the
4
+ * diagnostic path is `journalctl --user | grep -E '\\[quote-engine\\]|...'`.
5
+ */
6
+ export function logLine(s: string): void {
7
+ process.stderr.write(s + "\n");
8
+ }
@@ -0,0 +1,125 @@
1
+ // Execute the operator's persisted render script (quoting/render.mjs) to turn
2
+ // figures into the three reconciling HTML documents, then enforce the three
3
+ // deterministic gates and file each doc by its scope. The render script owns
4
+ // brand/layout as its own data; this module templates nothing. HTML only — the
5
+ // PDF is produced downstream by the browser plugin's browser-pdf-save.
6
+ //
7
+ // Fail-closed and atomic: if any gate fails, no document is filed and a
8
+ // cause-naming fault is returned. "Fail loudly and stop", never a partial issue.
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { gateReconcile, gateMarginLeak, gateSignLink, type SignLinkVerdict } from "./gates.js";
14
+
15
+ export type RenderFault = "render-absent" | "render-error" | "reconcile-fail" | "margin-leak" | "sign-link";
16
+
17
+ export interface RunRenderArgs {
18
+ accountDir: string;
19
+ renderScriptPath: string; // absolute
20
+ figures: Record<string, unknown>;
21
+ signUrl?: string;
22
+ jobId: string;
23
+ }
24
+
25
+ export type RenderResult =
26
+ | {
27
+ ok: true;
28
+ docs: { internal: string; breakdown: string; quote: string };
29
+ reconcile: { internal: boolean; breakdown: boolean; quote: boolean };
30
+ signLink: SignLinkVerdict;
31
+ }
32
+ | { ok: false; reason: RenderFault; detail: string };
33
+
34
+ interface DocHtml { internal: string; breakdown: string; quote: string }
35
+
36
+ export async function runRender(a: RunRenderArgs): Promise<RenderResult> {
37
+ if (!existsSync(a.renderScriptPath)) {
38
+ return { ok: false, reason: "render-absent", detail: `no render script at ${a.renderScriptPath}` };
39
+ }
40
+
41
+ const payload = JSON.stringify({ figures: a.figures, signUrl: a.signUrl ?? null });
42
+ const { code, stdout, stderr } = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => {
43
+ const child = spawn("node", [a.renderScriptPath], { cwd: a.accountDir });
44
+ let out = "";
45
+ let err = "";
46
+ child.stdout.on("data", (d) => (out += d));
47
+ child.stderr.on("data", (d) => (err += d));
48
+ child.on("error", (e) => resolve({ code: -1, stdout: out, stderr: String(e) }));
49
+ child.on("close", (c) => resolve({ code: c, stdout: out, stderr: err }));
50
+ // A script that exits before draining stdin closes the read end mid-write,
51
+ // raising EPIPE. Swallow it: the exit code from `close` carries the fault, so
52
+ // an early exit surfaces as render-error rather than crashing the server.
53
+ child.stdin.on("error", () => {});
54
+ child.stdin.write(payload);
55
+ child.stdin.end();
56
+ });
57
+
58
+ if (code !== 0) {
59
+ return { ok: false, reason: "render-error", detail: `render script exited ${code}: ${stderr.trim().slice(0, 400)}` };
60
+ }
61
+
62
+ let html: DocHtml;
63
+ try {
64
+ const parsed = JSON.parse(stdout) as Partial<DocHtml>;
65
+ if (typeof parsed.internal !== "string" || typeof parsed.breakdown !== "string" || typeof parsed.quote !== "string") {
66
+ return { ok: false, reason: "render-error", detail: "render output must be { internal, breakdown, quote } HTML strings" };
67
+ }
68
+ html = parsed as DocHtml;
69
+ } catch {
70
+ return { ok: false, reason: "render-error", detail: `render stdout was not JSON: ${stdout.trim().slice(0, 200)}` };
71
+ }
72
+
73
+ const engineTotal = Number(a.figures.total);
74
+ const phone = (a.figures.header as { phone?: string } | undefined)?.phone;
75
+ if (!phone) {
76
+ return { ok: false, reason: "render-error", detail: "figures.header.phone is required to file the documents by scope" };
77
+ }
78
+ // The phone is a filing-path segment; reject a separator or traversal so a
79
+ // crafted header cannot escape the customer scope, the same guard account.ts
80
+ // applies to ACCOUNT_ID.
81
+ if (phone.includes("/") || phone.includes("..")) {
82
+ return { ok: false, reason: "render-error", detail: `figures.header.phone contains a path separator or traversal: ${phone}` };
83
+ }
84
+
85
+ // Gate every document before filing any, so the log reports each doc's verdict
86
+ // and nothing is written when one fails.
87
+ const recInternal = gateReconcile(html.internal, engineTotal);
88
+ const recBreakdown = gateReconcile(html.breakdown, engineTotal);
89
+ const recQuote = gateReconcile(html.quote, engineTotal);
90
+ const leakBreakdown = gateMarginLeak(html.breakdown);
91
+ const leakQuote = gateMarginLeak(html.quote);
92
+ const signLink = gateSignLink(html.quote);
93
+
94
+ const internalOk = recInternal.pass;
95
+ const breakdownOk = recBreakdown.pass && leakBreakdown.pass;
96
+ const quoteOk = recQuote.pass && leakQuote.pass && signLink.pass;
97
+
98
+ if (!internalOk || !breakdownOk || !quoteOk) {
99
+ // Reason names the first failing gate, in document then gate order.
100
+ let reason: RenderFault;
101
+ let detail: string;
102
+ if (!recInternal.pass) { reason = "reconcile-fail"; detail = `internal total ${recInternal.shown} != engine ${engineTotal}`; }
103
+ else if (!recBreakdown.pass) { reason = "reconcile-fail"; detail = `breakdown total ${recBreakdown.shown} != engine ${engineTotal}`; }
104
+ else if (!leakBreakdown.pass) { reason = "margin-leak"; detail = "breakdown carries data-internal-only"; }
105
+ else if (!recQuote.pass) { reason = "reconcile-fail"; detail = `quote total ${recQuote.shown} != engine ${engineTotal}`; }
106
+ else if (!leakQuote.pass) { reason = "margin-leak"; detail = "quote carries data-internal-only"; }
107
+ else { reason = "sign-link"; detail = `quote Sign-Online link ${signLink.reason} (href=${signLink.href ?? ""})`; }
108
+ return { ok: false, reason, detail };
109
+ }
110
+
111
+ const internalPath = join(a.accountDir, "memory/admin/customers", phone, `quote-${a.jobId}-internal.html`);
112
+ const breakdownPath = join(a.accountDir, "memory/users", phone, "documents", `quote-${a.jobId}-breakdown.html`);
113
+ const quotePath = join(a.accountDir, "memory/users", phone, "documents", `quote-${a.jobId}.html`);
114
+ for (const [p, body] of [[internalPath, html.internal], [breakdownPath, html.breakdown], [quotePath, html.quote]] as const) {
115
+ mkdirSync(dirname(p), { recursive: true });
116
+ writeFileSync(p, body);
117
+ }
118
+
119
+ return {
120
+ ok: true,
121
+ docs: { internal: internalPath, breakdown: breakdownPath, quote: quotePath },
122
+ reconcile: { internal: internalOk, breakdown: breakdownOk, quote: quoteOk },
123
+ signLink,
124
+ };
125
+ }
@@ -0,0 +1,66 @@
1
+ // quote-engine-run — execute the operator's persisted pricing engine against a
2
+ // job and return structured figures. Runs the already-persisted engine; never
3
+ // regenerates it, never embeds a rate/catalogue/roll-up. A fault returns a
4
+ // cause-naming error and zero figures — never a partial or guessed total.
5
+
6
+ import { z } from "zod";
7
+ import { join } from "node:path";
8
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { lifelineTool } from "../../../../../../../platform/lib/mcp-lifeline/dist/index.js";
10
+ import { resolveAccountDir } from "../lib/account.js";
11
+ import { runEngine, writeFigures } from "../lib/engine-run.js";
12
+ import { logLine } from "../lib/log.js";
13
+
14
+ export function registerQuoteEngineRun(server: McpServer): void {
15
+ lifelineTool(
16
+ server,
17
+ "quote-engine-run",
18
+ "Execute the operator's already-persisted pricing engine (quoting/engine.mjs) against a job's quantities and return line prices, group subtotals and total. mode=verify runs the engine against a past job and returns the per-level reproduction delta. Runs the operator's own script; it is not a pricing model and embeds no rate, catalogue or roll-up. Faults (engine-absent, method-absent, script-error) return zero figures.",
19
+ {
20
+ jobId: z.string().describe("Correlation id; figures persist under quoting/jobs/<jobId>/."),
21
+ job: z.record(z.any()).describe("The job: header + lines. Passed to the engine verbatim; not interpreted here."),
22
+ mode: z.enum(["quote", "verify"]).default("quote").describe("quote = price the job; verify = reproduce a past job and return the delta."),
23
+ enginePath: z.string().default("quoting/engine.mjs").describe("Engine path relative to the account workspace."),
24
+ methodPath: z.string().default("quoting/method.json").describe("Method path relative to the account workspace."),
25
+ },
26
+ async (params: Record<string, unknown>) => {
27
+ const { jobId, job, mode, enginePath, methodPath } = params as {
28
+ jobId: string;
29
+ job: unknown;
30
+ mode: "quote" | "verify";
31
+ enginePath: string;
32
+ methodPath: string;
33
+ };
34
+ const accountId = process.env.ACCOUNT_ID ?? "local";
35
+ const accountDir = resolveAccountDir();
36
+ if (!accountDir) {
37
+ const detail = "ACCOUNT_DIR unset and PLATFORM_ROOT+ACCOUNT_ID unavailable";
38
+ logLine(`[quote-engine] op=fault jobId=${jobId} reason=no-account-context detail=${detail}`);
39
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, reason: "no-account-context", detail }) }], isError: true };
40
+ }
41
+
42
+ const absEngine = join(accountDir, enginePath);
43
+ const absMethod = join(accountDir, methodPath);
44
+ const r = await runEngine({ accountDir, enginePath: absEngine, methodPath: absMethod, job, mode });
45
+
46
+ if (!r.ok) {
47
+ logLine(`[quote-engine] op=fault jobId=${jobId} reason=${r.reason} detail=${r.detail}`);
48
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, reason: r.reason, detail: r.detail }, null, 2) }], isError: true };
49
+ }
50
+
51
+ if (mode === "verify") {
52
+ const deltas = (r.figures.deltas as unknown[]) ?? [];
53
+ const worst = (r.figures.worst as number) ?? 0;
54
+ logLine(`[quote-engine] op=verify jobId=${jobId} accountId=${accountId} worst=${worst} levels=${deltas.length}`);
55
+ return { content: [{ type: "text" as const, text: JSON.stringify({ jobId, deltas, worst }, null, 2) }] };
56
+ }
57
+
58
+ const total = r.figures.total as number;
59
+ const lineCount = Array.isArray(r.figures.lines) ? r.figures.lines.length : 0;
60
+ const reconcile = r.figures.reconcile;
61
+ const figuresPath = writeFigures(accountDir, jobId, r.figures);
62
+ logLine(`[quote-engine] op=run jobId=${jobId} accountId=${accountId} enginePath=${enginePath} methodPath=${methodPath} lines=${lineCount} total=${total}`);
63
+ return { content: [{ type: "text" as const, text: JSON.stringify({ jobId, total, lineCount, reconcile, figuresPath }, null, 2) }] };
64
+ },
65
+ );
66
+ }
@@ -0,0 +1,66 @@
1
+ // quote-render — turn engine figures into the three reconciling HTML documents
2
+ // via the operator's persisted render script, enforcing the reconcile,
3
+ // margin-leak and link-liveness gates, and filing each doc by its scope. HTML
4
+ // only: the PDF is produced downstream by the browser plugin's browser-pdf-save.
5
+ // A gate failure is fail-closed — no document is filed.
6
+
7
+ import { z } from "zod";
8
+ import { join } from "node:path";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { lifelineTool } from "../../../../../../../platform/lib/mcp-lifeline/dist/index.js";
12
+ import { resolveAccountDir } from "../lib/account.js";
13
+ import { runRender } from "../lib/render-run.js";
14
+ import { logLine } from "../lib/log.js";
15
+
16
+ export function registerQuoteRender(server: McpServer): void {
17
+ lifelineTool(
18
+ server,
19
+ "quote-render",
20
+ "Turn engine figures into the three reconciling HTML documents (internal margin view, client breakdown, client quote) via the operator's persisted quoting/render.mjs, enforcing three gates — reconcile (displayed total == engine total), margin-leak (no cost/margin in client docs), link-liveness (the client quote's Sign-Online href is absolute https). Files each doc by scope and returns the HTML paths. HTML only; print the PDFs with the browser plugin's browser-pdf-save. A gate failure files nothing.",
21
+ {
22
+ jobId: z.string().describe("Correlation id; figures load from quoting/jobs/<jobId>/figures.json unless figures is supplied inline."),
23
+ figures: z.record(z.any()).optional().describe("Inline figures (overrides the persisted figures.json) — for fixed-figure render parity."),
24
+ signUrl: z.string().optional().describe("The deployed e-sign signing URL injected into the client quote's acceptance anchor. Absent or non-https fails the link-liveness gate."),
25
+ renderScriptPath: z.string().default("quoting/render.mjs").describe("Render script path relative to the account workspace."),
26
+ },
27
+ async (params: Record<string, unknown>) => {
28
+ const { jobId, figures: inlineFigures, signUrl, renderScriptPath } = params as {
29
+ jobId: string;
30
+ figures?: Record<string, unknown>;
31
+ signUrl?: string;
32
+ renderScriptPath: string;
33
+ };
34
+ const accountDir = resolveAccountDir();
35
+ if (!accountDir) {
36
+ const detail = "ACCOUNT_DIR unset and PLATFORM_ROOT+ACCOUNT_ID unavailable";
37
+ logLine(`[quote-render] op=fault jobId=${jobId} reason=no-account-context detail=${detail}`);
38
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, reason: "no-account-context", detail }) }], isError: true };
39
+ }
40
+
41
+ let figures: Record<string, unknown>;
42
+ if (inlineFigures) {
43
+ figures = inlineFigures;
44
+ } else {
45
+ const figuresPath = join(accountDir, "quoting", "jobs", jobId, "figures.json");
46
+ if (!existsSync(figuresPath)) {
47
+ const detail = `no figures at ${figuresPath} — run quote-engine-run first`;
48
+ logLine(`[quote-render] op=fault jobId=${jobId} reason=figures-absent detail=${detail}`);
49
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, reason: "figures-absent", detail }) }], isError: true };
50
+ }
51
+ figures = JSON.parse(readFileSync(figuresPath, "utf8"));
52
+ }
53
+
54
+ const absRender = join(accountDir, renderScriptPath);
55
+ const r = await runRender({ accountDir, renderScriptPath: absRender, figures, signUrl, jobId });
56
+
57
+ if (!r.ok) {
58
+ logLine(`[quote-render] op=fault jobId=${jobId} reason=${r.reason} detail=${r.detail}`);
59
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, reason: r.reason, detail: r.detail }, null, 2) }], isError: true };
60
+ }
61
+
62
+ logLine(`[quote-render] op=render jobId=${jobId} docs=3 internal=${r.reconcile.internal} breakdown=${r.reconcile.breakdown} quote=${r.reconcile.quote}`);
63
+ return { content: [{ type: "text" as const, text: JSON.stringify({ jobId, docs: r.docs, reconcile: r.reconcile, signLink: r.signLink }, null, 2) }] };
64
+ },
65
+ );
66
+ }
@@ -0,0 +1,51 @@
1
+ // takeoff-compute — deterministic takeoff geometry over agent-supplied measured
2
+ // inputs. Reading the drawing and eliciting inputs stay in the takeoff skill;
3
+ // this tool only does the arithmetic. Carries no business content.
4
+
5
+ import { z } from "zod";
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { lifelineTool } from "../../../../../../../platform/lib/mcp-lifeline/dist/index.js";
8
+ import { computeTakeoff, type InLine, type Scale } from "../lib/geometry.js";
9
+ import { logLine } from "../lib/log.js";
10
+
11
+ export function registerTakeoffCompute(server: McpServer): void {
12
+ lifelineTool(
13
+ server,
14
+ "takeoff-compute",
15
+ "Deterministic takeoff geometry: turn agent-supplied measured inputs into a tagged quantity list — scale a measured length to its real dimension, a scaled area from two sides, roll up counts, and cross-check a scaled quantity against a printed figure. Reading the drawing and eliciting the inputs is the takeoff skill; this tool does only the arithmetic and carries no business content.",
16
+ {
17
+ jobId: z.string().describe("Correlation id for this quote/takeoff."),
18
+ scale: z
19
+ .union([z.object({ ratio: z.number() }), z.object({ measuredRef: z.number(), actual: z.number() })])
20
+ .optional()
21
+ .describe("Drawing scale: {ratio} for 1:R, or {measuredRef, actual} calibration. Required only when a line is scale-derived."),
22
+ lines: z
23
+ .array(
24
+ z.object({
25
+ element: z.string(),
26
+ basis: z.enum(["printed", "scaled", "count", "operator"]),
27
+ value: z.number().optional(),
28
+ unit: z.string().optional(),
29
+ measured: z.number().optional(),
30
+ measuredB: z.number().optional(),
31
+ kind: z.enum(["length", "area"]).optional(),
32
+ count: z.union([z.number(), z.array(z.number())]).optional(),
33
+ crossCheck: z.object({ value: z.number(), unit: z.string().optional(), tolerance: z.number().optional() }).optional(),
34
+ }),
35
+ )
36
+ .describe("The measured lines to compute."),
37
+ },
38
+ async (params: Record<string, unknown>) => {
39
+ const { jobId, scale, lines } = params as { jobId: string; scale?: Scale; lines: InLine[] };
40
+ try {
41
+ const r = computeTakeoff({ scale, lines });
42
+ logLine(`[takeoff-compute] op=compute jobId=${jobId} lines=${r.lines.length} exact=${r.counts.exact} scaled=${r.counts.scaled}`);
43
+ return { content: [{ type: "text" as const, text: JSON.stringify({ jobId, ...r }, null, 2) }] };
44
+ } catch (err) {
45
+ const msg = err instanceof Error ? err.message : String(err);
46
+ logLine(`[takeoff-compute] op=fault jobId=${jobId} detail=${msg}`);
47
+ return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
48
+ }
49
+ },
50
+ );
51
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src"],
19
+ "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
20
+ }
@@ -95,6 +95,30 @@ The engine reads one method and one job and produces the priced result. It must:
95
95
  (line, group, final) against the figures the owner actually issued and report each as a match or
96
96
  a mismatch.
97
97
 
98
+ ## The apply-path tool runs the engine
99
+
100
+ The deterministic apply step is the `sitedesk-job:quote-engine-run` MCP tool, not an inline Bash run
101
+ of the script. The tool executes the **already-persisted** engine and never regenerates it. Generate
102
+ the engine to the canonical layout and I/O boundary the tool invokes:
103
+
104
+ - the engine at `quoting/engine.mjs` and the method at `quoting/method.json`, both under the account
105
+ workspace (`$ACCOUNT_DIR`);
106
+ - the engine reads `{ methodPath, job, mode }` as one JSON object on **stdin**, loads its method from
107
+ `methodPath`, and writes its result as one JSON object on **stdout**;
108
+ - in `mode: "quote"` the result is the figures the seven duties above produce: `{ lines, groups,
109
+ rollup, total, reconcile }`;
110
+ - in `mode: "verify"` the result is the per-level delta `{ deltas, worst }` that feeds the
111
+ reproduction report in `references/verification.md`;
112
+ - a non-zero exit, non-JSON stdout, or a missing `total`/`reconcile` (quote) or `deltas` (verify) is
113
+ the fault the tool surfaces as `script-error`; a missing engine or method is `engine-absent` or
114
+ `method-absent`. Every fault returns zero figures, never a partial total.
115
+
116
+ `quote-engine-run` persists the quote-mode figures to `quoting/jobs/<jobId>/figures.json` and returns
117
+ `{ jobId, total, lineCount, reconcile }`. The render step (`quote-render`, in
118
+ `references/quote-generation.md`) reads those figures by `jobId`, so a Workflow chains
119
+ `quote-engine-run` then `quote-render` by passing `jobId` alone. Building and maintaining the engine's
120
+ content stays as this reference and the method-capture flow define; only the run seam is now the tool.
121
+
98
122
  ## Expressiveness — bounded by the owner's history
99
123
 
100
124
  Build the engine to support **exactly** the quantity, pricing, and roll-up forms the owner's own
@@ -32,6 +32,38 @@ equal the final total. You never recompute these figures — you display them.
32
32
  The engine itself, and how a line's quantity, rate, cost and sell are derived, are out of scope here
33
33
  (that is the compute core). This contract only reads the result.
34
34
 
35
+ ## The apply-path tool renders the documents
36
+
37
+ Rendering is the `sitedesk-job:quote-render` MCP tool, not hand-authored HTML each run. The tool runs
38
+ the operator's **persisted render script** at `quoting/render.mjs` (the render analog of the pricing
39
+ engine): it reads `{ figures, signUrl }` on stdin and writes the three documents as one JSON object of
40
+ HTML strings on stdout, `{ internal, breakdown, quote }`. The script holds the operator's brand,
41
+ layout, headings, narrative, charts, page size, contact and terms as their own data. The tool
42
+ templates nothing and carries no brand. Generating and maintaining that render script is the build
43
+ half of this skill, exactly as generating the pricing engine is the build half of the compute core.
44
+
45
+ The render script marks three stable attributes so the tool gates the output deterministically,
46
+ whatever the operator's brand or language:
47
+
48
+ - **`data-quote-total`** on each document's displayed grand total, carrying the numeric total, so the
49
+ tool reconciles it against the engine total;
50
+ - **`data-internal-only`** on every cost, margin, or internal-rate figure in the internal view, so the
51
+ tool asserts the two client documents carry none;
52
+ - **`data-esign-accept`** on the client quote's Sign-Online anchor (one line, double-quoted `href`), so
53
+ the tool asserts the acceptance link is live.
54
+
55
+ `quote-render` enforces three fail-closed gates before it files anything: **reconcile** (each
56
+ document's `data-quote-total` equals the engine total), **margin-leak** (no `data-internal-only` in the
57
+ client breakdown or client quote), and **link-liveness** (the client quote's `data-esign-accept` href
58
+ is an absolute `https://` URL). If any gate fails the tool files nothing and returns a cause-naming
59
+ fault (`reconcile-fail`, `margin-leak`, `sign-link`, `render-absent`, `render-error`). On success it
60
+ files each document to its scope (below) and returns the three HTML paths plus the gate verdicts.
61
+
62
+ The tool produces **HTML, not PDF**. Print each returned path to PDF with the `browser` plugin as the
63
+ final step: `browser-navigate` to its `file://` URL, then `browser-pdf-save` to a `.pdf` alongside it,
64
+ confirming a non-zero byte count. A Workflow chains `quote-engine-run`, then `quote-render`, then one
65
+ `browser` `browser-pdf-save` step per document.
66
+
35
67
  ## The three documents
36
68
 
37
69
  The operator defines the content and styling of each; the **roles** are fixed, the **contents** are
@@ -64,17 +96,19 @@ rendered until the deploy has run**. The order is fixed and load-bearing:
64
96
 
65
97
  1. run **e-sign § 5** to deploy the signing page and take its returned absolute `https://` URL (e-sign
66
98
  § 5 names it as an explicit hand-back);
67
- 2. inject that URL as the `href` of the acceptance block's Sign-Online anchor;
68
- 3. render the client quote to PDF (`Producing the PDF`, below), through the link-liveness gate;
99
+ 2. pass that URL to `quote-render` as `signUrl`; the render script injects it as the `href` of the
100
+ acceptance block's `data-esign-accept` anchor, and the tool's link-liveness gate holds it to an
101
+ absolute `https://` target or files nothing;
102
+ 3. print the returned client-quote HTML to PDF (`Producing the PDF`, below);
69
103
  4. send.
70
104
 
71
- Mark that one anchor with a stable attribute `data-esign-accept` so the pre-export gate (`Before
72
- you hand anything to the PDF tool`, below) finds it deterministically, whatever the operator's brand,
73
- wording, or language. The gate reads the anchor's opening tag line by line, so emit that opening tag
74
- on a **single line** with a **double-quoted `href`** (the default for these self-contained documents);
75
- a split tag or single-quoted `href` reads as no anchor and fails the gate closed. The anchor's `href`
76
- is **never** a placeholder, a bare `#`, or a `file://`/relative path: it is the deployed signing URL
77
- or the quote does not ship.
105
+ The render script marks that one anchor with the stable attribute `data-esign-accept` so
106
+ `quote-render`'s link-liveness gate finds it deterministically, whatever the operator's brand,
107
+ wording, or language. The gate matches the anchor's opening tag, so the render script emits that
108
+ opening tag on a **single line** with a **double-quoted `href`** (the default for these self-contained
109
+ documents); a split tag or single-quoted `href` reads as no anchor and fails the gate closed. The
110
+ anchor's `href` is **never** a placeholder, a bare `#`, or a `file://`/relative path: it is the
111
+ deployed signing URL or the quote does not ship.
78
112
 
79
113
  When the quote is **confidential to a named client**, the acceptance is gated per e-sign § 1a. That
80
114
  section's **same-origin invariant** is load-bearing: the `/api/accept` and `/api/status` capture
@@ -113,51 +147,33 @@ the document:
113
147
 
114
148
  ## Producing the PDF
115
149
 
116
- Render the document as self-contained HTML (styles inline, images by absolute local path), then use
117
- the same path as every other business-assistant document:
150
+ `quote-render` renders each document as self-contained HTML (the render script inlines styles and
151
+ references images by absolute local path) and files it to its filing-scope path, returning the paths.
152
+ For each returned path, print it the same way as every other business-assistant document:
118
153
 
119
- 1. write the HTML to the document's filing-scope path;
120
- 2. `browser-navigate` to it via a `file://` absolute URL;
121
- 3. `browser-pdf-save` to an absolute `.pdf` path alongside the HTML;
122
- 4. confirm a non-zero byte count from the `Saved PDF to <path> (<bytes>)` line before treating the PDF
154
+ 1. `browser-navigate` to it via a `file://` absolute URL;
155
+ 2. `browser-pdf-save` to an absolute `.pdf` path alongside the HTML;
156
+ 3. confirm a non-zero byte count from the `Saved PDF to <path> (<bytes>)` line before treating the PDF
123
157
  as done.
124
158
 
125
- ## Before you hand anything to the PDF tool
126
-
127
- Log, for the run: which document roles were produced, each one's page count, and the reconciliation —
128
- each document's displayed total against the engine total. If a document's total does not reconcile to
129
- the engine total, fail loudly and stop. Never ship a document whose figures disagree with the engine.
130
-
131
- **Link-liveness gate the client quote's Sign-Online anchor must resolve to the live signing page.**
132
- The reconciliation above guards the *figures*; it says nothing about whether the acceptance block's
133
- Sign-Online link is live. A client quote whose acceptance link is empty, a bare fragment (`#…`),
134
- relative, `file://`, `about:`, or any non-`https://` target must **fail loudly and stop** — no PDF
135
- written, nothing sent exactly as a reconciliation mismatch does. Run this deterministic,
136
- exit-code-gated assertion over the **rendered client-quote HTML**, after it is written (`Producing the
137
- PDF` step 1) and **before `browser-pdf-save`** (step 3). It is authored as an assertion, not prose,
138
- because a dead link emits no other signal:
139
-
140
- ```bash
141
- # Link-liveness gate. QUOTE_HTML = the client quote's written HTML (its filing-scope path);
142
- # DOC_REF = the e-sign document reference for this quote (for the log line).
143
- tag=$(grep -oiE '<a[^>]*\bdata-esign-accept\b[^>]*>' "$QUOTE_HTML" | head -1)
144
- href=$(printf '%s' "$tag" | grep -oiE 'href="[^"]*"' | head -1 | sed -E 's/^href="(.*)"$/\1/')
145
- verdict() { echo "op=sign-link doc_ref=${DOC_REF} href=${href} live=$1"; }
146
- if [ -z "$tag" ]; then verdict "fail reason=no-anchor"; exit 1; fi
147
- case "$href" in
148
- https://*) verdict ok ;;
149
- "") verdict "fail reason=empty"; exit 1 ;;
150
- '#'*) verdict "fail reason=fragment"; exit 1 ;;
151
- *://*|*:*) verdict "fail reason=non-https"; exit 1 ;; # http://, file://, about:, mailto:
152
- *) verdict "fail reason=relative"; exit 1 ;; # /path, path, ./x, ../x
153
- esac
154
- ```
155
-
156
- A client quote PDF exists **only** when this line read `live=ok`. The per-run log for a client quote
157
- therefore carries one `op=sign-link doc_ref=<ref> href=<value> live=ok` line — naming the absolute
158
- `https://` href — alongside the roles / page-count / reconciliation lines above. A `Saved PDF to
159
- <path>` line for a client quote with no preceding `live=ok` for the same `doc_ref` is itself a
160
- regression signature: the gate was bypassed.
159
+ ## The tool enforces the reconciliation and link-liveness gates
160
+
161
+ `quote-render` runs three fail-closed gates over the rendered HTML before it files any document, so
162
+ these checks are enforced deterministically at render time, not as a separate hand-run step:
163
+
164
+ - **reconciliation.** Each document's `data-quote-total` must equal the engine total. A mismatch files
165
+ nothing and returns `reconcile-fail`. Never ship a document whose figures disagree with the engine.
166
+ - **margin-leak.** The client breakdown and client quote must carry no `data-internal-only` marker. A
167
+ leak files nothing and returns `margin-leak`. The margin boundary is structural, not a matter of
168
+ remembering to omit a column.
169
+ - **link-liveness.** The client quote's `data-esign-accept` href must be an absolute `https://` URL,
170
+ not empty, a bare fragment (`#…`), relative, `file://`, `about:`, or any non-https target. A dead
171
+ link files nothing and returns `sign-link`, because a dead link emits no other signal.
172
+
173
+ The tool emits one `[quote-render] op=render jobId=… docs=3 internal=… breakdown=… quote=…` line on
174
+ success and `[quote-render] op=fault jobId=… reason=… detail=…` on any gate failure. A `Saved PDF to
175
+ <path>` line for a client quote with no preceding successful `op=render` for the same job is itself a
176
+ regression signature: the render gate was bypassed.
161
177
 
162
178
  **No-event standing check — filed quotes against live signing pages.** The gate fires only while a
163
179
  quote is being produced; it cannot catch a quote that shipped a bad link before the gate existed.
@@ -55,6 +55,18 @@ Every line names how its quantity was measured. There are four bases and no othe
55
55
 
56
56
  `references/measuring.md` carries how to establish the reference and work each basis.
57
57
 
58
+ ## Computing the quantities
59
+
60
+ Reading the drawing and eliciting the missing figures is this skill's work. Turning the readings into
61
+ quantities is the `sitedesk-job:takeoff-compute` tool. Pass it the measured lines, each tagged with its
62
+ basis, and (when any line is scale-derived) the scale as a ratio or a known-dimension calibration. The
63
+ tool applies the deterministic arithmetic (a scaled length from the scale, a scaled area from two
64
+ measured sides, a count roll-up by summation, and printed and operator-supplied figures passed through
65
+ exactly) and returns the tagged quantity list with a count of how many lines are exact versus
66
+ scale-approximated. Where a line carries a printed figure to check a scaled reading against, the tool
67
+ records the delta and whether it disagrees beyond the tolerance you give. The tool measures nothing off
68
+ the drawing itself and asks the operator nothing; those stay this skill's job.
69
+
58
70
  ## Cross-check
59
71
 
60
72
  Where a quantity is scale-derived and the drawing also prints a dimension for the same element, record