@rubytech/create-maxy-code 0.1.97 → 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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/.claude-plugin/marketplace.json +5 -0
  3. package/payload/platform/plugins/founder-pack/.claude-plugin/plugin.json +8 -0
  4. package/payload/platform/plugins/founder-pack/skills/investor-data-room/SKILL.md +218 -0
  5. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/business-plan-template.md +61 -0
  6. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/compliance-research-checklist.md +53 -0
  7. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/data-room-structure.md +77 -0
  8. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/deck-blueprint-template.md +38 -0
  9. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/design-tokens-application.md +79 -0
  10. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/html-pdf-pipeline.md +236 -0
  11. package/payload/platform/plugins/founder-pack/skills/investor-data-room/references/internal-workings-scrub.md +33 -0
  12. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js +79 -87
  14. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/scripts/stash-cleanup.sh +410 -0
  16. package/payload/premium-plugins/.claude-plugin/marketplace.json +5 -0
  17. package/payload/premium-plugins/founder-pack/.claude-plugin/plugin.json +8 -0
  18. package/payload/premium-plugins/founder-pack/skills/investor-data-room/SKILL.md +218 -0
  19. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/business-plan-template.md +61 -0
  20. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/compliance-research-checklist.md +53 -0
  21. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/data-room-structure.md +77 -0
  22. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/deck-blueprint-template.md +38 -0
  23. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/design-tokens-application.md +79 -0
  24. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/html-pdf-pipeline.md +236 -0
  25. package/payload/premium-plugins/founder-pack/skills/investor-data-room/references/internal-workings-scrub.md +33 -0
@@ -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;AA6ED,eAAO,MAAM,kBAAkB,QAAoC,CAAA;AAQnE,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,CAi3CpD"}
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 the resume-conflict stash composite
136
- // id `<uuid>.<unix-ms>` minted at http-server.ts:1006/1013 and surfaced
137
- // by Task 260's archive enumerator. Dot is admitted; length raised to
138
- // fit UUID (36) + `.` + 13-digit ms; the negative lookahead rejects
139
- // all-dot strings so path-traversal shapes (`.`, `..`, `...`) cannot
140
- // satisfy the gate. No `/` Hono's route param already excludes it,
141
- // and admitting it here would broaden the filesystem surface the
142
- // downstream `jsonlPathForSessionId` composition trusts.
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 are minted at http-server.ts:1013 when
145
- // a resume-conflict preserves a smaller JSONL at `<uuid>.<unix-ms>` in
146
- // the archive directory. They are addressable (Task 262 admitted them
147
- // at the HTTP gate) but not resumable: `claude --resume` expects a
148
- // UUID, not a composite. The pattern lower-bounds the digit run at 13
149
- // to match the millisecond timestamp the writer emits.
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) {
@@ -900,10 +902,15 @@ export function buildHttpApp(deps) {
900
902
  : undefined;
901
903
  const sourceRow = resolveRow(deps.watcher, sessionId);
902
904
  const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
903
- // Task 258 — restore the archived jsonl to its canonical top-level path
904
- // before claude --resume runs. Claude appends to `<projectsDir>/<sid>.jsonl`;
905
- // if the file lives in `archive/`, claude doesn't find it and starts a fresh
906
- // empty transcript at the top-level path, silently splitting the conversation.
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.
907
914
  const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
908
915
  const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
909
916
  const canonicalExists = existsSync(canonicalJsonl);
@@ -934,32 +941,22 @@ export function buildHttpApp(deps) {
934
941
  }
935
942
  }
936
943
  else if (archivedExists && canonicalExists) {
937
- // Both files exist pre-Task-258 bug already produced two parallel
938
- // jsonls for one sessionId. Keep the larger as canonical (more history);
939
- // park the smaller at `archive/<sid>.<ts>.jsonl`. No data loss.
940
- try {
941
- const canonicalSize = statSync(canonicalJsonl).size;
942
- const archivedSize = statSync(archivedJsonl).size;
943
- const keepArchived = archivedSize > canonicalSize;
944
- const ts = Date.now();
945
- if (keepArchived) {
946
- const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
947
- renameSync(canonicalJsonl, stash);
948
- renameSync(archivedJsonl, canonicalJsonl);
949
- deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-archived canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
950
- }
951
- else {
952
- const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
953
- renameSync(archivedJsonl, stash);
954
- deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-canonical canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
955
- }
956
- }
957
- catch (err) {
958
- const msg = err instanceof Error ? err.message : String(err);
959
- deps.logger(`resume-conflict-resolve-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
960
- timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
961
- return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
962
- }
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);
963
960
  }
964
961
  const result = await spawnClaudeSession({
965
962
  ...deps,
@@ -1138,9 +1135,13 @@ export function buildHttpApp(deps) {
1138
1135
  //
1139
1136
  // Promotes a stash-composite JSONL (`<uuid>.<unix-ms>.jsonl`) back to
1140
1137
  // its canonical UUID path (`<uuid>.jsonl`) so a normal `/resume` against
1141
- // the UUID can re-attach a PTY to the preserved conversation. The
1142
- // resume-conflict path (Task 258) mints stash composites; this is the
1143
- // door that opens before `/resume` can fire on one.
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.
1144
1145
  //
1145
1146
  // Deterministic refusals, no spawn attempted:
1146
1147
  // 400 invalid-session-id — pattern check fails
@@ -1148,18 +1149,22 @@ export function buildHttpApp(deps) {
1148
1149
  // 409 pty-still-alive-on-canonical — isLive(uuid) is true; clobbering
1149
1150
  // the canonical jsonl would corrupt
1150
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.
1151
1157
  // 409 stash-ambiguous-location — the same stash id exists both at
1152
1158
  // the project root AND under archive/
1153
1159
  // 404 stash-not-found — neither location has the file
1154
1160
  // (raced with Purge in another tab)
1155
1161
  //
1156
1162
  // Happy path:
1157
- // - If canonical `<uuid>.jsonl` exists, rename it aside to
1158
- // `<uuid>.<Date.now()>.jsonl` (top-level) first, preserving the
1159
- // displaced history as a fresh stash sibling — same rule as
1160
- // Task 258's resume-conflict path.
1161
1163
  // - renameSync(stashPath, canonicalPath); sidecar follows.
1162
- // - 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.
1163
1168
  app.post('/:sessionId/promote', async (c) => {
1164
1169
  const start = Date.now();
1165
1170
  const id = c.req.param('sessionId');
@@ -1208,35 +1213,26 @@ export function buildHttpApp(deps) {
1208
1213
  : sidecarPathForSessionId(deps.spawnCwd, id, { archived: true });
1209
1214
  const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, uuid);
1210
1215
  const canonicalSidecar = sidecarPathForSessionId(deps.spawnCwd, uuid);
1211
- // Conflict resolutiondisplace canonical to a fresh stash sibling
1212
- // at the project root. Sidecar follows. Same rule as the
1213
- // resume-conflict path in /resume (Task 258).
1214
- let displacedStashId = null;
1216
+ // Task 273refuse 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.
1215
1223
  if (existsSync(canonicalJsonl)) {
1216
- const ts = Date.now();
1217
- const displacedId = `${uuid}.${ts}`;
1218
- const displacedJsonl = jsonlPathForSessionId(deps.spawnCwd, displacedId);
1219
- const displacedSidecar = sidecarPathForSessionId(deps.spawnCwd, displacedId);
1220
- try {
1221
- renameSync(canonicalJsonl, displacedJsonl);
1222
- if (existsSync(canonicalSidecar)) {
1223
- try {
1224
- renameSync(canonicalSidecar, displacedSidecar);
1225
- }
1226
- catch (err) {
1227
- deps.logger(`[http-server] promote-displace-sidecar-failed sessionId=${id.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
1228
- }
1229
- }
1230
- displacedStashId = displacedId;
1231
- }
1232
- catch (err) {
1233
- const msg = err instanceof Error ? err.message : String(err);
1234
- deps.logger(`[http-server] promote-displace-failed sessionId=${id.slice(0, 8)} message=${msg}`);
1235
- timed(deps.logger, 'POST', `/${id}/promote`, 500, Date.now() - start);
1236
- return c.json({ error: 'promote-failed', detail: msg }, 500);
1237
- }
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);
1238
1233
  }
1239
- // Promote — stash → canonical. Sidecar follows.
1234
+ // Promote — stash → canonical. Sidecar follows. No prior displacement
1235
+ // means no displacedStashId can ever be non-null.
1240
1236
  try {
1241
1237
  renameSync(stashJsonl, canonicalJsonl);
1242
1238
  if (existsSync(stashSidecar)) {
@@ -1250,17 +1246,13 @@ export function buildHttpApp(deps) {
1250
1246
  }
1251
1247
  catch (err) {
1252
1248
  const msg = err instanceof Error ? err.message : String(err);
1253
- // Forensic completeness: when the canonical was already displaced
1254
- // before this throw, the operator must know where the original
1255
- // canonical now lives on disk. Without `displacedStashId` here, the
1256
- // 500 looks like "promote did nothing" — recoverable but opaque.
1257
- 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}`);
1258
1250
  timed(deps.logger, 'POST', `/${id}/promote`, 500, Date.now() - start);
1259
- return c.json({ error: 'promote-failed', detail: msg, displacedStashId }, 500);
1251
+ return c.json({ error: 'promote-failed', detail: msg, displacedStashId: null }, 500);
1260
1252
  }
1261
- deps.logger(`[http-server] promote sessionId=${id.slice(0, 8)} uuid=${uuid.slice(0, 8)} displaced-stash=${displacedStashId ? displacedStashId.slice(0, 8) : 'null'}`);
1253
+ deps.logger(`[http-server] promote sessionId=${id.slice(0, 8)} uuid=${uuid.slice(0, 8)} displaced-stash=null`);
1262
1254
  timed(deps.logger, 'POST', `/${id}/promote`, 200, Date.now() - start);
1263
- return c.json({ canonicalSessionId: uuid, displacedStashId }, 200);
1255
+ return c.json({ canonicalSessionId: uuid, displacedStashId: null }, 200);
1264
1256
  });
1265
1257
  // Task 253 — POST /:sessionId/rename { title }
1266
1258
  // - 200 + body on accept (carries the persisted title)