@rubytech/create-maxy-code 0.1.96 → 0.1.98
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.
- package/package.json +1 -1
- package/payload/platform/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/platform/plugins/founder-pack/.claude-plugin/plugin.json +8 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/SKILL.md +218 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/business-plan-template.md +61 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/compliance-research-checklist.md +53 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/data-room-structure.md +77 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/deck-blueprint-template.md +38 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/design-tokens-application.md +79 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/html-pdf-pipeline.md +236 -0
- package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/internal-workings-scrub.md +33 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +125 -95
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/scripts/stash-cleanup.sh +410 -0
- package/payload/premium-plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/founder-pack/.claude-plugin/plugin.json +8 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/SKILL.md +218 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/business-plan-template.md +61 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/compliance-research-checklist.md +53 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/data-room-structure.md +77 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/deck-blueprint-template.md +38 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/design-tokens-application.md +79 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/html-pdf-pipeline.md +236 -0
- package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/internal-workings-scrub.md +33 -0
- package/payload/server/public/assets/{admin-Bt4mImui.js → admin-WyIfDMQG.js} +5 -5
- package/payload/server/public/assets/{data-laRW9zIG.js → data-D2aPJB7w.js} +1 -1
- package/payload/server/public/assets/{graph-SnA01HCP.js → graph-B5UWg8zV.js} +1 -1
- package/payload/server/public/assets/graph-labels-BzMN9eev.js +1 -0
- package/payload/server/public/assets/{page-8KS8gKWq.js → page--nZ_z-z7.js} +1 -1
- package/payload/server/public/assets/{page-BSgRQNig.js → page-D93GZ3YQ.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/public/assets/graph-labels-BGa1MHXG.js +0 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# HTML + PDF render pipeline
|
|
2
|
+
|
|
3
|
+
Image-only A4 portrait PDF, rendered from a single HTML file via Playwright + img2pdf + qpdf. Mirrors the constraints in the `property-brochure:a4-print-documents` skill.
|
|
4
|
+
|
|
5
|
+
## File layout per artefact
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
html/<artefact>/
|
|
9
|
+
index.html # source of truth
|
|
10
|
+
render-pdf.mjs # Playwright pipeline
|
|
11
|
+
<artefact>.pdf # rendered output
|
|
12
|
+
logo-light.png # for dark cover/backpage
|
|
13
|
+
logo-dark.png # for light content pages (optional)
|
|
14
|
+
README.md # render + verify commands, page list
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## HTML scaffold (key blocks)
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<!DOCTYPE html>
|
|
21
|
+
<html lang="en-GB">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="UTF-8">
|
|
24
|
+
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Inter:wght@400;500;600;700&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
|
25
|
+
<style>
|
|
26
|
+
/* Tier 1 + Tier 2 design tokens from project's design-tokens.md */
|
|
27
|
+
:root {
|
|
28
|
+
--teal-900: #0E1418;
|
|
29
|
+
--teal-950: #06080A;
|
|
30
|
+
--gold-500: #A4884C;
|
|
31
|
+
--gold-300: #C9B07A;
|
|
32
|
+
--gold-700: #7A6235;
|
|
33
|
+
--paper-0: #FFFFFF;
|
|
34
|
+
--paper-25: #FBFAF6;
|
|
35
|
+
--paper-100: #ECF1F2;
|
|
36
|
+
--slate-400: #8A8E96;
|
|
37
|
+
--slate-600: #5C6470;
|
|
38
|
+
--rule-fine: #E2D5BD;
|
|
39
|
+
--serif: 'Cormorant Garamond', serif;
|
|
40
|
+
--body-serif: 'Lora', Georgia, serif;
|
|
41
|
+
--sans: 'Inter', sans-serif;
|
|
42
|
+
--surface-page: var(--paper-25);
|
|
43
|
+
--surface-deep: var(--teal-950);
|
|
44
|
+
--text-primary: var(--teal-900);
|
|
45
|
+
--text-on-deep: var(--paper-25);
|
|
46
|
+
--text-accent: var(--gold-700);
|
|
47
|
+
--text-accent-on-deep: var(--gold-300);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@page { size: A4 portrait; margin: 18mm 16mm 20mm; }
|
|
51
|
+
@page :first { margin: 0; }
|
|
52
|
+
@page backpage-full { margin: 0; }
|
|
53
|
+
|
|
54
|
+
body {
|
|
55
|
+
margin: 0;
|
|
56
|
+
background: var(--surface-page);
|
|
57
|
+
color: var(--text-primary);
|
|
58
|
+
font-family: var(--body-serif);
|
|
59
|
+
font-size: 11pt;
|
|
60
|
+
line-height: 1.55;
|
|
61
|
+
counter-reset: pagenum;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.page {
|
|
65
|
+
counter-increment: pagenum;
|
|
66
|
+
position: relative;
|
|
67
|
+
}
|
|
68
|
+
.page:not(.page--cover):not(.page--backpage)::after {
|
|
69
|
+
content: counter(pagenum);
|
|
70
|
+
position: absolute;
|
|
71
|
+
bottom: 10mm;
|
|
72
|
+
right: 16mm;
|
|
73
|
+
font-family: var(--sans);
|
|
74
|
+
font-size: 9pt;
|
|
75
|
+
letter-spacing: 0.28em;
|
|
76
|
+
color: var(--slate-400);
|
|
77
|
+
}
|
|
78
|
+
.page:not(.page--cover):not(.page--backpage)::before {
|
|
79
|
+
content: "REAL AGENT NETWORK LIMITED · BUSINESS PLAN · v1.0";
|
|
80
|
+
position: absolute;
|
|
81
|
+
bottom: 10mm;
|
|
82
|
+
left: 16mm;
|
|
83
|
+
font-family: var(--sans);
|
|
84
|
+
font-size: 7pt;
|
|
85
|
+
letter-spacing: 0.24em;
|
|
86
|
+
color: var(--slate-400);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@media screen {
|
|
90
|
+
body { background: #FFFFFF; padding: 24px; }
|
|
91
|
+
.page {
|
|
92
|
+
width: 210mm; min-height: 297mm;
|
|
93
|
+
margin: 20px auto; padding: 18mm 16mm 20mm;
|
|
94
|
+
background: var(--surface-page);
|
|
95
|
+
box-shadow: 0 4px 28px rgba(0,0,0,0.14);
|
|
96
|
+
}
|
|
97
|
+
.page--cover, .page--backpage { padding: 0; overflow: hidden; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@media print {
|
|
101
|
+
body { background: var(--surface-page); padding: 0; }
|
|
102
|
+
.page { page-break-after: always; padding: 0; }
|
|
103
|
+
.page--cover, .page--backpage {
|
|
104
|
+
margin: 0; width: auto; box-shadow: none;
|
|
105
|
+
height: 100vh; position: relative; overflow: hidden;
|
|
106
|
+
-webkit-print-color-adjust: exact;
|
|
107
|
+
print-color-adjust: exact;
|
|
108
|
+
}
|
|
109
|
+
.page--backpage { page-break-before: always; page: backpage-full; }
|
|
110
|
+
.download-btn { display: none !important; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.download-btn {
|
|
114
|
+
position: fixed; top: 20px; right: 20px; z-index: 100;
|
|
115
|
+
font-family: var(--sans); font-size: 9pt; letter-spacing: 0.28em;
|
|
116
|
+
text-transform: uppercase; color: var(--paper-0);
|
|
117
|
+
background: var(--teal-900); padding: 11px 18px 12px;
|
|
118
|
+
border-radius: 9999px; text-decoration: none;
|
|
119
|
+
box-shadow: 0 4px 16px rgba(14, 20, 24, 0.18);
|
|
120
|
+
}
|
|
121
|
+
.download-btn:hover { background: var(--gold-700); }
|
|
122
|
+
</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<a class="download-btn" href="<artefact>.pdf" download>Download PDF</a>
|
|
126
|
+
|
|
127
|
+
<section class="page page--cover" data-screen-label="cover"> ... </section>
|
|
128
|
+
<section class="page" data-screen-label="exec-summary"> ... </section>
|
|
129
|
+
<!-- ... one <section class="page"> per A4 page ... -->
|
|
130
|
+
<section class="page page--backpage" data-screen-label="backpage"> ... </section>
|
|
131
|
+
</body>
|
|
132
|
+
</html>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## render-pdf.mjs (canonical)
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
#!/usr/bin/env node
|
|
139
|
+
import { chromium } from 'playwright';
|
|
140
|
+
import { execFileSync } from 'node:child_process';
|
|
141
|
+
import { mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
142
|
+
import { dirname, basename, join, resolve, extname } from 'node:path';
|
|
143
|
+
import { pathToFileURL } from 'node:url';
|
|
144
|
+
|
|
145
|
+
const [, , htmlInput, pdfOutput] = process.argv;
|
|
146
|
+
if (!htmlInput || !pdfOutput) {
|
|
147
|
+
console.error('Usage: render-pdf.mjs <input.html> <output.pdf>');
|
|
148
|
+
process.exit(64);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const htmlPath = resolve(htmlInput);
|
|
152
|
+
const pdfPath = resolve(pdfOutput);
|
|
153
|
+
const tmpDir = resolve(dirname(pdfPath), `.tmp-${basename(pdfPath, extname(pdfPath))}`);
|
|
154
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
155
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
const VIEWPORT_W = 794; // A4 portrait at 96 dpi
|
|
158
|
+
const VIEWPORT_H = 1123;
|
|
159
|
+
const SCALE = 3.125; // → 300 dpi PNGs
|
|
160
|
+
const A4_HEIGHT_PX_SLACK = VIEWPORT_H + 2;
|
|
161
|
+
|
|
162
|
+
(async () => {
|
|
163
|
+
const browser = await chromium.launch();
|
|
164
|
+
const context = await browser.newContext({
|
|
165
|
+
viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
|
|
166
|
+
deviceScaleFactor: SCALE,
|
|
167
|
+
});
|
|
168
|
+
const page = await context.newPage();
|
|
169
|
+
|
|
170
|
+
await page.goto(pathToFileURL(htmlPath).toString(), { waitUntil: 'networkidle' });
|
|
171
|
+
await page.emulateMedia({ media: 'print' });
|
|
172
|
+
await page.evaluate(() => document.fonts ? document.fonts.ready : Promise.resolve());
|
|
173
|
+
await page.evaluate(() => { document.body.style.padding = '0'; });
|
|
174
|
+
|
|
175
|
+
const sections = await page.$$('section.page');
|
|
176
|
+
if (sections.length === 0) {
|
|
177
|
+
console.error('No <section class="page"> elements found.');
|
|
178
|
+
process.exit(65);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const pngFiles = [];
|
|
182
|
+
let overflow = false;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < sections.length; i++) {
|
|
185
|
+
const section = sections[i];
|
|
186
|
+
const label = await section.getAttribute('data-screen-label') || `page${i + 1}`;
|
|
187
|
+
const scrollHeight = await section.evaluate(el => el.scrollHeight);
|
|
188
|
+
if (scrollHeight > A4_HEIGHT_PX_SLACK) {
|
|
189
|
+
console.error(`[OVERFLOW] page ${i + 1} (${label}): ${scrollHeight}px > A4 + 2px slack`);
|
|
190
|
+
overflow = true;
|
|
191
|
+
}
|
|
192
|
+
const outPath = join(tmpDir, `page-${String(i + 1).padStart(2, '0')}-${label}.png`);
|
|
193
|
+
await section.scrollIntoViewIfNeeded();
|
|
194
|
+
await section.screenshot({ path: outPath, type: 'png' });
|
|
195
|
+
pngFiles.push(outPath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await browser.close();
|
|
199
|
+
if (overflow) {
|
|
200
|
+
console.error('Overflow detected. Aborting.');
|
|
201
|
+
process.exit(4);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const preLinearize = join(tmpDir, 'pre-linearize.pdf');
|
|
205
|
+
execFileSync('img2pdf', ['--pagesize', '210mmx297mm', '--output', preLinearize, ...pngFiles], { stdio: 'inherit' });
|
|
206
|
+
execFileSync('qpdf', ['--linearize', '--object-streams=disable', preLinearize, pdfPath], { stdio: 'inherit' });
|
|
207
|
+
|
|
208
|
+
console.log(`✓ PDF written: ${pdfPath}`);
|
|
209
|
+
})();
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Verification
|
|
213
|
+
|
|
214
|
+
After every render:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
qpdf --check <artefact>.pdf # → "No syntax or stream encoding errors found"
|
|
218
|
+
pdfinfo <artefact>.pdf | grep -E "Pages|Optimized" # → confirms page count + linearization
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Then **read every page visually**. `pdfinfo` reporting "25 pages, A4" is not verification.
|
|
222
|
+
|
|
223
|
+
## Dependencies
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
npm i -D playwright && npx playwright install chromium
|
|
227
|
+
brew install img2pdf qpdf
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Hard rules
|
|
231
|
+
|
|
232
|
+
- One `<section class="page">` per A4 page. Renderer aborts (exit 4) on overflow.
|
|
233
|
+
- `execFileSync` (never `execSync` with template strings).
|
|
234
|
+
- Cover and backpage are full-bleed dark teal (`--surface-deep`); skip page numbers and running footer via `:not(.page--cover):not(.page--backpage)` selectors on the page-counter pseudo-elements.
|
|
235
|
+
- Even page count for booklet binding (multiple of 4 ideal).
|
|
236
|
+
- Logo: copy the white-mono icon variant as `logo-light.png` for dark cover/backpage; copy the dark icon variant as `logo-dark.png` if used on light content pages.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Internal-workings scrub — public-vs-internal doctrine
|
|
2
|
+
|
|
3
|
+
Public-facing documents (business plan, prospectus, deck, term sheet, anything the investor reads) state the **current position** only. The journey to that position, the reasoning behind it, comparisons against prior baselines, open items still being reconciled, and cross-references to internal sections all stay in working documents in the data-room's internal sections.
|
|
4
|
+
|
|
5
|
+
## Patterns that must NOT appear in public docs
|
|
6
|
+
|
|
7
|
+
- **Comparison to baseline / prior version.** *"The loss has grown from -£282.6k to -£323.1k because…"*, *"Total opex (M0 customer-success hire adds £40,500 vs the M10 model worksheet baseline)"*.
|
|
8
|
+
- **Process commentary.** *"Numbers below use the model's published figures pending the reconciled rebuild"*, *"reconciliation pending"*, *"open item, not actioned here"*, *"see Section X critical note and Section Y contingency levers for the reconciliation paths"*.
|
|
9
|
+
- **Internal cross-references to scaffolding.** *"see Section 12.3 critical note"*, *"see Section 12.6 contingency levers"* when the linked content itself is process-talk rather than substance.
|
|
10
|
+
- **Discrepancy resolution narratives.** *"The earlier '£0.01 vs £0.001 nominal' note is resolved by treating the £4,500 as share premium"*.
|
|
11
|
+
- **"Open Q" or "Open item" markers in body text.** Belongs in section READMEs and the office-hours design doc only.
|
|
12
|
+
- **Date-coded internal designs.** *"2026-04-22 design"*, *"office-hours design doc"*, *"model worksheet (Appendix A) needs a rebuild"*.
|
|
13
|
+
- **"Pre-contingency-lever activation" / "once X is included" hedges in summary tables.**
|
|
14
|
+
- **Explanatory parentheticals that name the process.** *"(M10-trigger model did not carry)"* is process commentary and must go.
|
|
15
|
+
|
|
16
|
+
## What IS acceptable in a public-facing document
|
|
17
|
+
|
|
18
|
+
- The current position, stated as a fact.
|
|
19
|
+
- A footnote citing a source (Companies House, IBISWorld, ICO, etc.) when the figure derives from public data.
|
|
20
|
+
- A pointer to a public appendix (titled documents only; not internal-section READMEs).
|
|
21
|
+
- Conditions on a forecast figure that an investor needs to know (e.g. "assuming Round 2 closes at M9-M10").
|
|
22
|
+
|
|
23
|
+
## The same content, internal vs public
|
|
24
|
+
|
|
25
|
+
| Internal version (working doc) | Public version (business plan) |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Total operating expenses ~£649,100 (M0 customer-success hire adds £40,500 vs the M10 model worksheet baseline). | Total operating expenses ~£649,100. |
|
|
28
|
+
| Year-1 P&L loss has grown from -£282.6k to -£323.1k because the customer-success hire is brought forward from M10 to M0. | Year-1 P&L loss is approximately £323k against revenue of £356k. |
|
|
29
|
+
| Cash crosses zero at M8–M9 if Round 2 does not close; the model worksheet's M11 £34k cash low arrives roughly two months earlier under the M0 CS hire structure. | The raise is sized to deliver the Round 2 gating milestones; Round 2 closes at M9 to M10. |
|
|
30
|
+
|
|
31
|
+
## Recurrence is a P0 violation
|
|
32
|
+
|
|
33
|
+
In the reference project this rule was violated three times in one session (initial draft used a dated internal design reference; appendix table referenced internal-only documents alongside externally-shareable ones; M0 customer-success hire updates added "model worksheet baseline" comparisons). The pattern: every time the founder adds content or asks for an update, the temptation is to include "what changed and why" alongside the new value. The temptation must be resisted; the public document carries only the new value. The "what changed and why" goes in a sibling internal document if it needs to live somewhere.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAI3B,OAAO,EAYL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAKzB,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAoB9E;4CAC4C;AAC5C,wBAAgB,+BAA+B,IAAI,IAAI,CAEtD;
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAI3B,OAAO,EAYL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAKzB,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAoB9E;4CAC4C;AAC5C,wBAAgB,+BAA+B,IAAI,IAAI,CAEtD;AA8ED,eAAO,MAAM,kBAAkB,QAAoC,CAAA;AASnE,eAAO,MAAM,uBAAuB,QAA4B,CAAA;AAIhE,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;CAC/B;AAqID;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CA62CpD"}
|
|
@@ -132,21 +132,23 @@ function parseSpecialistDomains(raw) {
|
|
|
132
132
|
return out.length > 0 ? out : undefined;
|
|
133
133
|
}
|
|
134
134
|
// `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
|
|
135
|
-
// bridgeSuffix, pid (numeric), or
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// fit UUID (36) + `.` +
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
135
|
+
// bridgeSuffix, pid (numeric), or a legacy stash composite id
|
|
136
|
+
// `<uuid>.<unix-ms>` left on disk by pre-Task-273 code (the platform
|
|
137
|
+
// no longer mints them) and still surfaced by Task 260's archive
|
|
138
|
+
// enumerator. Dot is admitted; length raised to fit UUID (36) + `.` +
|
|
139
|
+
// 13-digit ms; the negative lookahead rejects all-dot strings so
|
|
140
|
+
// path-traversal shapes (`.`, `..`, `...`) cannot satisfy the gate.
|
|
141
|
+
// No `/` — Hono's route param already excludes it, and admitting it
|
|
142
|
+
// here would broaden the filesystem surface the downstream
|
|
143
|
+
// `jsonlPathForSessionId` composition trusts.
|
|
143
144
|
export const SESSION_ID_PATTERN = /^(?!\.+$)[A-Za-z0-9._-]{1,160}$/;
|
|
144
|
-
// Task 263 — stash-composite IDs
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
145
|
+
// Task 263 / Task 273 — stash-composite IDs were minted by the
|
|
146
|
+
// pre-Task-273 resume-conflict and promote-displacement paths at
|
|
147
|
+
// `<uuid>.<unix-ms>`. Task 273 removed both minting sites; this
|
|
148
|
+
// pattern now matches only legacy on-disk composites and is the gate
|
|
149
|
+
// the /resume and /promote routes use to refuse or promote them. The
|
|
150
|
+
// pattern lower-bounds the digit run at 13 to match the millisecond
|
|
151
|
+
// timestamp the prior writer emitted.
|
|
150
152
|
export const STASH_COMPOSITE_PATTERN = /^[a-f0-9-]{36}\.\d{13}$/;
|
|
151
153
|
const LIST_TAIL_BYTES = 64 * 1024;
|
|
152
154
|
function timed(logger, method, path, status, ms) {
|
|
@@ -612,6 +614,19 @@ export function buildHttpApp(deps) {
|
|
|
612
614
|
const connectStart = Date.now();
|
|
613
615
|
let active = true;
|
|
614
616
|
let subscribersCount = 0;
|
|
617
|
+
// Task 272 — paired dedup for the live→archived demote sequence.
|
|
618
|
+
// The watcher fires `delete pid` immediately followed by `modify jsonl`
|
|
619
|
+
// for the same sessionId; the pid-delete branch emits row-archived and
|
|
620
|
+
// we must swallow the trailing modify so the client sees one
|
|
621
|
+
// transition. Pre-Task-272 the dedup matched on row state instead, so
|
|
622
|
+
// every modify-jsonl on a non-live row was dropped — including
|
|
623
|
+
// archive↔top renames on stash composites and sidecar refreshes on
|
|
624
|
+
// ended rows. Window is comfortably wider than the watcher's per-path
|
|
625
|
+
// debounce (~30 ms in production) so back-to-back events on the same
|
|
626
|
+
// sessionId pair correctly without dropping unrelated mutations
|
|
627
|
+
// arriving later in the connection.
|
|
628
|
+
const pendingDedupAt = new Map();
|
|
629
|
+
const DEDUP_WINDOW_MS = 100;
|
|
615
630
|
// Replay phase. Emit a `row-created` for every row in the index so
|
|
616
631
|
// a freshly-connected tab catches up to the manager's view before
|
|
617
632
|
// any subsequent delta arrives.
|
|
@@ -640,6 +655,11 @@ export function buildHttpApp(deps) {
|
|
|
640
655
|
if (event.kind === 'delete' && event.source === 'pid' && event.row) {
|
|
641
656
|
const payload = rowToPayload(event.row);
|
|
642
657
|
subscribersCount += 1;
|
|
658
|
+
// Task 272 — arm the paired dedup BEFORE the synchronous emit so
|
|
659
|
+
// the trailing modify-jsonl on the same sessionId (delivered by
|
|
660
|
+
// the same subscriber callback, off the same debounce flush) hits
|
|
661
|
+
// an entry that has not yet expired.
|
|
662
|
+
pendingDedupAt.set(event.sessionId, Date.now());
|
|
643
663
|
deps.logger(`[admin-events] emit event=row-archived sessionId=${event.sessionId} subscribers=1`);
|
|
644
664
|
void out.write(`event: row-archived\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
645
665
|
return;
|
|
@@ -660,20 +680,40 @@ export function buildHttpApp(deps) {
|
|
|
660
680
|
void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
|
|
661
681
|
return;
|
|
662
682
|
}
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
683
|
+
// Task 272 — paired dedup for the live→archived demote sequence.
|
|
684
|
+
// Only swallow the modify-jsonl that immediately follows a
|
|
685
|
+
// delete-pid for the SAME sessionId, within the debounce window.
|
|
686
|
+
// The pre-Task-272 predicate (row no longer live AND pidFilePath
|
|
687
|
+
// null) was too broad: it caught archive↔top renames on stash
|
|
688
|
+
// composites and sidecar refreshes on ended rows, leaving the
|
|
689
|
+
// sidebar stuck on the pre-rename state.
|
|
690
|
+
if (event.kind === 'modify' && event.source === 'jsonl' && event.row) {
|
|
691
|
+
const dedupTs = pendingDedupAt.get(event.sessionId);
|
|
692
|
+
if (dedupTs !== undefined && Date.now() - dedupTs <= DEDUP_WINDOW_MS) {
|
|
693
|
+
pendingDedupAt.delete(event.sessionId);
|
|
694
|
+
deps.logger(`[admin-events] dedup-paired sessionId=${event.sessionId} reason=pid-delete-coalesce`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Drop any stale pending entry so it cannot dedup an unrelated
|
|
698
|
+
// future modify on the same sessionId.
|
|
699
|
+
if (dedupTs !== undefined)
|
|
700
|
+
pendingDedupAt.delete(event.sessionId);
|
|
670
701
|
}
|
|
671
702
|
if (!event.row)
|
|
672
703
|
return;
|
|
673
704
|
const sseEvent = event.kind === 'create' ? 'row-created' : 'row-updated';
|
|
674
705
|
const payload = rowToPayload(event.row);
|
|
675
706
|
subscribersCount += 1;
|
|
676
|
-
|
|
707
|
+
// Task 272 — the previously-swallowed case (state !== 'live' AND
|
|
708
|
+
// pidFilePath === null with no paired pid-delete in the window)
|
|
709
|
+
// now emits. Tag it with reason=jsonl-modify-non-live so the
|
|
710
|
+
// regression is visible in a single grep against server logs.
|
|
711
|
+
const previouslySwallowed = event.kind === 'modify'
|
|
712
|
+
&& event.source === 'jsonl'
|
|
713
|
+
&& event.row.state !== 'live'
|
|
714
|
+
&& event.row.pidFilePath === null;
|
|
715
|
+
const reasonSuffix = previouslySwallowed ? ' reason=jsonl-modify-non-live' : '';
|
|
716
|
+
deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1${reasonSuffix}`);
|
|
677
717
|
void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
678
718
|
});
|
|
679
719
|
// Task 189 / 260 — when `url-capture` fires for a session, the
|
|
@@ -862,10 +902,15 @@ export function buildHttpApp(deps) {
|
|
|
862
902
|
: undefined;
|
|
863
903
|
const sourceRow = resolveRow(deps.watcher, sessionId);
|
|
864
904
|
const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
|
|
865
|
-
// Task 258 — restore the archived jsonl to its canonical
|
|
866
|
-
// before claude --resume runs. Claude
|
|
867
|
-
//
|
|
868
|
-
//
|
|
905
|
+
// Task 258 / Task 273 — restore the archived jsonl to its canonical
|
|
906
|
+
// top-level path before claude --resume runs. Claude CLI manages
|
|
907
|
+
// resume natively as one file per UUID: it appends to
|
|
908
|
+
// `<projectsDir>/<sid>.jsonl`. If the file lives in `archive/`,
|
|
909
|
+
// claude doesn't find it and starts a fresh empty transcript at the
|
|
910
|
+
// top-level path, silently splitting the conversation. The platform
|
|
911
|
+
// does not synthesise `<uuid>.<unix-ms>` stash composites — when
|
|
912
|
+
// both archived and canonical exist, that is an invariant break, not
|
|
913
|
+
// a parallel fork to recover.
|
|
869
914
|
const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
|
|
870
915
|
const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
|
|
871
916
|
const canonicalExists = existsSync(canonicalJsonl);
|
|
@@ -896,32 +941,22 @@ export function buildHttpApp(deps) {
|
|
|
896
941
|
}
|
|
897
942
|
}
|
|
898
943
|
else if (archivedExists && canonicalExists) {
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
renameSync(archivedJsonl, stash);
|
|
916
|
-
deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-canonical canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
catch (err) {
|
|
920
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
921
|
-
deps.logger(`resume-conflict-resolve-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
|
|
922
|
-
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
923
|
-
return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
|
|
924
|
-
}
|
|
944
|
+
// Task 273 — both files exist. The pre-Task-258 minting bug is
|
|
945
|
+
// closed; if we see this state, an external write has put us back
|
|
946
|
+
// in the split-conversation regime. Refuse loudly. The operator
|
|
947
|
+
// resolves on the filesystem (typically: keep the larger, quarantine
|
|
948
|
+
// the smaller — same rule the Pi cleanup script applies), then
|
|
949
|
+
// retries resume. No stash composite is minted by this code path.
|
|
950
|
+
const canonicalSize = statSync(canonicalJsonl).size;
|
|
951
|
+
const archivedSize = statSync(archivedJsonl).size;
|
|
952
|
+
deps.logger(`[http-server] resume-collision-detected sessionId=${sessionId.slice(0, 8)} canonical-size=${canonicalSize} archived-size=${archivedSize}`);
|
|
953
|
+
timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
|
|
954
|
+
return c.json({
|
|
955
|
+
error: 'resume-collision-detected',
|
|
956
|
+
detail: 'both archive/<sid>.jsonl and top-level <sid>.jsonl exist — resolve on disk before retrying',
|
|
957
|
+
canonicalSize,
|
|
958
|
+
archivedSize,
|
|
959
|
+
}, 500);
|
|
925
960
|
}
|
|
926
961
|
const result = await spawnClaudeSession({
|
|
927
962
|
...deps,
|
|
@@ -1100,9 +1135,13 @@ export function buildHttpApp(deps) {
|
|
|
1100
1135
|
//
|
|
1101
1136
|
// Promotes a stash-composite JSONL (`<uuid>.<unix-ms>.jsonl`) back to
|
|
1102
1137
|
// its canonical UUID path (`<uuid>.jsonl`) so a normal `/resume` against
|
|
1103
|
-
// the UUID can re-attach a PTY to the preserved conversation.
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1138
|
+
// the UUID can re-attach a PTY to the preserved conversation.
|
|
1139
|
+
//
|
|
1140
|
+
// Task 273 — the platform no longer mints stash composites; resume and
|
|
1141
|
+
// promote both refuse rather than rename canonical aside. Existing
|
|
1142
|
+
// stash composites on disk (left by the pre-Task-273 minting bug) are
|
|
1143
|
+
// promotable only when the canonical UUID has no jsonl AND no live
|
|
1144
|
+
// PTY. Anything else is an operator-resolves state.
|
|
1106
1145
|
//
|
|
1107
1146
|
// Deterministic refusals, no spawn attempted:
|
|
1108
1147
|
// 400 invalid-session-id — pattern check fails
|
|
@@ -1110,18 +1149,22 @@ export function buildHttpApp(deps) {
|
|
|
1110
1149
|
// 409 pty-still-alive-on-canonical — isLive(uuid) is true; clobbering
|
|
1111
1150
|
// the canonical jsonl would corrupt
|
|
1112
1151
|
// the live PTY's transcript
|
|
1152
|
+
// 409 canonical-already-populated — canonical `<uuid>.jsonl` exists
|
|
1153
|
+
// on disk (no live PTY). The operator
|
|
1154
|
+
// resolves on the filesystem (purge,
|
|
1155
|
+
// or move the canonical aside manually)
|
|
1156
|
+
// before retrying promote.
|
|
1113
1157
|
// 409 stash-ambiguous-location — the same stash id exists both at
|
|
1114
1158
|
// the project root AND under archive/
|
|
1115
1159
|
// 404 stash-not-found — neither location has the file
|
|
1116
1160
|
// (raced with Purge in another tab)
|
|
1117
1161
|
//
|
|
1118
1162
|
// Happy path:
|
|
1119
|
-
// - If canonical `<uuid>.jsonl` exists, rename it aside to
|
|
1120
|
-
// `<uuid>.<Date.now()>.jsonl` (top-level) first, preserving the
|
|
1121
|
-
// displaced history as a fresh stash sibling — same rule as
|
|
1122
|
-
// Task 258's resume-conflict path.
|
|
1123
1163
|
// - renameSync(stashPath, canonicalPath); sidecar follows.
|
|
1124
|
-
// - 200 { canonicalSessionId, displacedStashId }
|
|
1164
|
+
// - 200 { canonicalSessionId, displacedStashId: null }
|
|
1165
|
+
// displacedStashId is always null in the new contract; the field
|
|
1166
|
+
// stays on the wire for client compatibility but never carries a
|
|
1167
|
+
// non-null value.
|
|
1125
1168
|
app.post('/:sessionId/promote', async (c) => {
|
|
1126
1169
|
const start = Date.now();
|
|
1127
1170
|
const id = c.req.param('sessionId');
|
|
@@ -1170,35 +1213,26 @@ export function buildHttpApp(deps) {
|
|
|
1170
1213
|
: sidecarPathForSessionId(deps.spawnCwd, id, { archived: true });
|
|
1171
1214
|
const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, uuid);
|
|
1172
1215
|
const canonicalSidecar = sidecarPathForSessionId(deps.spawnCwd, uuid);
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
|
|
1216
|
+
// Task 273 — refuse on canonical-already-populated. The pre-Task-273
|
|
1217
|
+
// displacement path renamed the canonical aside to a fresh
|
|
1218
|
+
// `<uuid>.<unix-ms>` sibling and compounded the on-disk debris. The
|
|
1219
|
+
// new contract is: promote only when the canonical slot is empty.
|
|
1220
|
+
// If a file sits there with no live PTY, the operator chose to keep
|
|
1221
|
+
// it (purge would have removed it); resolving the collision is an
|
|
1222
|
+
// operator decision, not a silent rename.
|
|
1177
1223
|
if (existsSync(canonicalJsonl)) {
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
}
|
|
1188
|
-
catch (err) {
|
|
1189
|
-
deps.logger(`[http-server] promote-displace-sidecar-failed sessionId=${id.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
displacedStashId = displacedId;
|
|
1193
|
-
}
|
|
1194
|
-
catch (err) {
|
|
1195
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1196
|
-
deps.logger(`[http-server] promote-displace-failed sessionId=${id.slice(0, 8)} message=${msg}`);
|
|
1197
|
-
timed(deps.logger, 'POST', `/${id}/promote`, 500, Date.now() - start);
|
|
1198
|
-
return c.json({ error: 'promote-failed', detail: msg }, 500);
|
|
1199
|
-
}
|
|
1224
|
+
const canonicalSize = statSync(canonicalJsonl).size;
|
|
1225
|
+
deps.logger(`[http-server] promote-refused sessionId=${id.slice(0, 8)} reason=canonical-already-populated uuid=${uuid.slice(0, 8)} canonical-size=${canonicalSize}`);
|
|
1226
|
+
timed(deps.logger, 'POST', `/${id}/promote`, 409, Date.now() - start);
|
|
1227
|
+
return c.json({
|
|
1228
|
+
error: 'canonical-already-populated',
|
|
1229
|
+
detail: `canonical <uuid>.jsonl exists on disk (${canonicalSize} bytes) — purge or manually resolve before retrying promote`,
|
|
1230
|
+
canonicalSessionId: uuid,
|
|
1231
|
+
canonicalSize,
|
|
1232
|
+
}, 409);
|
|
1200
1233
|
}
|
|
1201
|
-
// Promote — stash → canonical. Sidecar follows.
|
|
1234
|
+
// Promote — stash → canonical. Sidecar follows. No prior displacement
|
|
1235
|
+
// means no displacedStashId can ever be non-null.
|
|
1202
1236
|
try {
|
|
1203
1237
|
renameSync(stashJsonl, canonicalJsonl);
|
|
1204
1238
|
if (existsSync(stashSidecar)) {
|
|
@@ -1212,17 +1246,13 @@ export function buildHttpApp(deps) {
|
|
|
1212
1246
|
}
|
|
1213
1247
|
catch (err) {
|
|
1214
1248
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1215
|
-
|
|
1216
|
-
// before this throw, the operator must know where the original
|
|
1217
|
-
// canonical now lives on disk. Without `displacedStashId` here, the
|
|
1218
|
-
// 500 looks like "promote did nothing" — recoverable but opaque.
|
|
1219
|
-
deps.logger(`[http-server] promote-failed sessionId=${id.slice(0, 8)} displaced-stash=${displacedStashId ?? 'null'} message=${msg}`);
|
|
1249
|
+
deps.logger(`[http-server] promote-failed sessionId=${id.slice(0, 8)} message=${msg}`);
|
|
1220
1250
|
timed(deps.logger, 'POST', `/${id}/promote`, 500, Date.now() - start);
|
|
1221
|
-
return c.json({ error: 'promote-failed', detail: msg, displacedStashId }, 500);
|
|
1251
|
+
return c.json({ error: 'promote-failed', detail: msg, displacedStashId: null }, 500);
|
|
1222
1252
|
}
|
|
1223
|
-
deps.logger(`[http-server] promote sessionId=${id.slice(0, 8)} uuid=${uuid.slice(0, 8)} displaced-stash
|
|
1253
|
+
deps.logger(`[http-server] promote sessionId=${id.slice(0, 8)} uuid=${uuid.slice(0, 8)} displaced-stash=null`);
|
|
1224
1254
|
timed(deps.logger, 'POST', `/${id}/promote`, 200, Date.now() - start);
|
|
1225
|
-
return c.json({ canonicalSessionId: uuid, displacedStashId }, 200);
|
|
1255
|
+
return c.json({ canonicalSessionId: uuid, displacedStashId: null }, 200);
|
|
1226
1256
|
});
|
|
1227
1257
|
// Task 253 — POST /:sessionId/rename { title }
|
|
1228
1258
|
// - 200 + body on accept (carries the persisted title)
|