@foldspace_npm/harness 0.1.32 → 0.1.34

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 (36) hide show
  1. package/CLAUDE.md +169 -49
  2. package/README.md +30 -2
  3. package/bin/inject.mjs +2 -18
  4. package/bin/observe.mjs +18 -0
  5. package/bin/record.mjs +2 -0
  6. package/bin/share.mjs +63 -0
  7. package/bin/sync.mjs +48 -0
  8. package/package.json +1 -1
  9. package/recipes/INDEX.md +1 -1
  10. package/recipes/bottom-bar/README.md +24 -11
  11. package/recipes/bottom-bar/agent/bottomBar.ts +76 -38
  12. package/recipes/bottom-bar/fixtures/configuration.sent.json +13 -12
  13. package/recipes/swap-the-login-method/README.md +1 -1
  14. package/recipes/swap-the-login-method/agent/utils.ts +8 -7
  15. package/recipes/swap-the-login-method/variants/utils.cookies.ts +8 -7
  16. package/src/chrome-binary.mjs +33 -0
  17. package/src/cli-registry.mjs +96 -5
  18. package/src/customer-subdomain.mjs +149 -0
  19. package/src/lint/index.mjs +23 -3
  20. package/src/lint/rules.mjs +46 -2
  21. package/src/lint/safety.mjs +785 -0
  22. package/src/observe-core.mjs +1 -1
  23. package/src/runtime/VERSION +1 -1
  24. package/src/runtime/config.ts +15 -2
  25. package/src/runtime/http.ts +40 -3
  26. package/src/runtime/index.ts +1 -0
  27. package/src/sync-core.mjs +374 -0
  28. package/src/upgrade.mjs +36 -7
  29. package/src/video/compose.mjs +12 -3
  30. package/src/video/recorder.mjs +15 -7
  31. package/src/video/share.mjs +284 -0
  32. package/templates/agent-starter/agent/constants.ts +15 -1
  33. package/templates/agent-starter/agent/utils.ts +3 -0
  34. package/templates/agent-starter/claude-settings.json +3 -1
  35. package/templates/agent-starter/skills/review-experience/SKILL.md +135 -0
  36. package/templates/agent-starter/skills/share-a-video/SKILL.md +47 -4
@@ -0,0 +1,284 @@
1
+ // foldspace share - the bytes half of sharing a video by a Foldspace link.
2
+ //
3
+ // The CLI holds no Foldspace sign-in (the editor's MCP does), so sharing is
4
+ // three steps the agent takes, only after the human said yes:
5
+ // 1. `npx foldspace share inspect` - sizes and checks the MP4 and poster here
6
+ // 2. MCP `create_video_share` - returns one-time signed upload targets
7
+ // (`upload_command`), then `npx foldspace share upload` sends the files
8
+ // straight to Foldspace's video storage with them
9
+ // 3. MCP `publish_video_share` - the server checks the files and returns
10
+ // the public link
11
+ // Nothing here talks to Foldspace except the upload targets the MCP issued,
12
+ // and nothing prints them: a target is a bearer credential for one object.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ /** GCS requires every chunk but the last to be a multiple of 256 KiB. */
18
+ export const CHUNK_UNIT_BYTES = 256 * 1024;
19
+ export const DEFAULT_CHUNK_BYTES = 32 * CHUNK_UNIT_BYTES; // 8 MiB
20
+ const MAX_ATTEMPTS_PER_CHUNK = 4;
21
+
22
+ /**
23
+ * The only hosts an upload may go to: Google Cloud Storage, where Foldspace's video bucket is, and
24
+ * this machine for the local demo. A target anywhere else is refused, so a tampered or injected
25
+ * upload command cannot send the human's recording somewhere else.
26
+ */
27
+ export function isAllowedUploadUrl(value) {
28
+ let url;
29
+ try {
30
+ url = new URL(value);
31
+ } catch {
32
+ return false;
33
+ }
34
+ if (url.protocol === "https:" && url.hostname === "storage.googleapis.com") return true;
35
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
36
+ }
37
+
38
+ /** Mirrors the server's caps (share-service SHARE_MAX_*): checked here so a too-big file fails before any upload. */
39
+ export const MAX_VIDEO_BYTES = 500 * 1024 * 1024;
40
+ export const MAX_POSTER_BYTES = 5 * 1024 * 1024;
41
+
42
+ export class ShareError extends Error {
43
+ constructor(code, message) {
44
+ super(message);
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ /** The newest video/*.mp4 that `record` made, with its poster beside it. */
50
+ export function defaultVideo(projectDir) {
51
+ const dir = path.join(projectDir, "video");
52
+ let names = [];
53
+ try {
54
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".mp4"));
55
+ } catch {
56
+ // no video folder
57
+ }
58
+ if (!names.length) {
59
+ throw new ShareError("NO_VIDEO", "No video/*.mp4 in this project. Make one with `npx foldspace record`, or pass the MP4's path.");
60
+ }
61
+ const newest = names
62
+ .map((name) => ({ name, mtime: fs.statSync(path.join(dir, name)).mtimeMs }))
63
+ .sort((a, b) => b.mtime - a.mtime)[0].name;
64
+ return path.join(dir, newest);
65
+ }
66
+
67
+ export function posterFor(videoPath) {
68
+ return videoPath.replace(/\.mp4$/i, ".jpg");
69
+ }
70
+
71
+ /**
72
+ * Sizes the two files and runs the same structural checks the server's publish
73
+ * check runs (MP4 with ftyp, moov before mdat; JPEG poster), so the agent hears
74
+ * "re-encode with faststart" before uploading 500 MB rather than after.
75
+ */
76
+ export function inspectForShare({ videoPath, posterPath }) {
77
+ const video = statFile(videoPath, "NO_VIDEO", "video");
78
+ const poster = statFile(posterPath, "NO_POSTER", "poster");
79
+ if (video.bytes > MAX_VIDEO_BYTES) throw new ShareError("TOO_LARGE", `The video is ${mb(video.bytes)} MB; the limit is ${mb(MAX_VIDEO_BYTES)} MB.`);
80
+ if (poster.bytes > MAX_POSTER_BYTES) throw new ShareError("TOO_LARGE", `The poster is ${mb(poster.bytes)} MB; the limit is ${mb(MAX_POSTER_BYTES)} MB.`);
81
+
82
+ const head = readSlice(posterPath, 0, 3);
83
+ if (!(head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff)) {
84
+ throw new ShareError("NOT_JPEG", "The poster must be a JPEG image.");
85
+ }
86
+ const durationS = readMp4Duration(videoPath, video.bytes);
87
+ return { video: { ...video, durationS }, poster };
88
+ }
89
+
90
+ /** Walks top-level boxes: ftyp first, moov before mdat; returns mvhd duration in seconds. */
91
+ function readMp4Duration(file, size) {
92
+ const first = boxAt(file, 0, size);
93
+ if (!first || first.type !== "ftyp") throw new ShareError("NOT_MP4", "The video is not an MP4 file.");
94
+ let offset = first.size;
95
+ for (let i = 0; i < 64 && offset < size; i++) {
96
+ const box = boxAt(file, offset, size);
97
+ if (!box) throw new ShareError("NOT_MP4", "The MP4 is truncated or damaged.");
98
+ if (box.type === "mdat") {
99
+ throw new ShareError("NOT_FAST_START", "The MP4 is not fast-start (its moov box comes after the video data). Re-encode it with `-movflags +faststart`.");
100
+ }
101
+ if (box.type === "moov") {
102
+ const body = readSlice(file, offset + box.header, Math.min(box.size - box.header, 16 * 1024 * 1024));
103
+ return mvhdDurationSeconds(body);
104
+ }
105
+ offset += box.size;
106
+ }
107
+ throw new ShareError("NOT_MP4", "The MP4 has no moov box.");
108
+ }
109
+
110
+ function mvhdDurationSeconds(moov) {
111
+ let offset = 0;
112
+ while (offset + 8 <= moov.length) {
113
+ const size = moov.readUInt32BE(offset);
114
+ const type = moov.toString("latin1", offset + 4, offset + 8);
115
+ if (size < 8) break;
116
+ if (type === "mvhd") {
117
+ const body = moov.subarray(offset + 8, offset + size);
118
+ if (body[0] === 1) return Number(body.readBigUInt64BE(24)) / body.readUInt32BE(20);
119
+ return body.readUInt32BE(16) / body.readUInt32BE(12);
120
+ }
121
+ offset += size;
122
+ }
123
+ throw new ShareError("NOT_MP4", "The MP4 has no movie header.");
124
+ }
125
+
126
+ function boxAt(file, offset, fileSize) {
127
+ if (offset + 8 > fileSize) return null;
128
+ const head = readSlice(file, offset, 16);
129
+ let size = head.readUInt32BE(0);
130
+ let header = 8;
131
+ if (size === 1) {
132
+ size = Number(head.readBigUInt64BE(8));
133
+ header = 16;
134
+ } else if (size === 0) {
135
+ size = fileSize - offset;
136
+ }
137
+ if (size < header || offset + size > fileSize) return null;
138
+ return { type: head.toString("latin1", 4, 8), size, header };
139
+ }
140
+
141
+ /**
142
+ * Parses the `--targets` JSON that `create_video_share` put in the upload
143
+ * command: { video: {url, method, headers}, poster: {...} }.
144
+ */
145
+ export function parseTargets(text) {
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(String(text || ""));
149
+ } catch {
150
+ throw new ShareError("BAD_TARGETS", "--targets is not valid JSON. Copy the upload command from create_video_share exactly.");
151
+ }
152
+ for (const name of ["video", "poster"]) {
153
+ const target = parsed?.[name];
154
+ if (!target || typeof target.url !== "string" || target.method !== "POST" || typeof target.headers !== "object") {
155
+ throw new ShareError("BAD_TARGETS", `--targets has no valid ${name} target. Copy the upload command from create_video_share exactly.`);
156
+ }
157
+ if (!isAllowedUploadUrl(target.url)) {
158
+ throw new ShareError("BAD_TARGETS", `The ${name} upload target is not Foldspace's video storage (https://storage.googleapis.com). Use only the command create_video_share returned.`);
159
+ }
160
+ }
161
+ return parsed;
162
+ }
163
+
164
+ /**
165
+ * GCS resumable upload: POST the signed URL to start a session, then PUT the
166
+ * file in chunks with Content-Range. On a dropped connection it asks the
167
+ * session how much arrived (`bytes * /total`) and resumes from there.
168
+ * Errors name the HTTP status and GCS's error code, never the URL.
169
+ */
170
+ export async function resumableUpload(target, filePath, { fetchImpl = fetch, chunkBytes = DEFAULT_CHUNK_BYTES, label = "file" } = {}) {
171
+ if (chunkBytes % CHUNK_UNIT_BYTES !== 0) throw new Error("chunkBytes must be a multiple of 256 KiB");
172
+ const total = fs.statSync(filePath).size;
173
+ if (total === 0) throw new ShareError("UPLOAD_FAILED", `The ${label} is empty.`);
174
+ // redirect: "manual" everywhere, never "error": GCS answers every unfinished chunk with 308 "Resume
175
+ // Incomplete", which fetch would turn into a network error. A 3xx that names a Location is a real
176
+ // redirect, and is refused (rejectRedirect): storage never sends an upload somewhere else.
177
+ const start = rejectRedirect(await fetchImpl(target.url, { method: "POST", headers: target.headers, body: "", redirect: "manual" }), label);
178
+ if (start.status !== 200 && start.status !== 201) {
179
+ throw new ShareError("UPLOAD_REFUSED", `Starting the ${label} upload failed: ${await describe(start)}. The upload targets last 30 minutes; if they expired, call create_video_share again.`);
180
+ }
181
+ const session = start.headers.get("location");
182
+ if (!session || !isAllowedUploadUrl(session)) throw new ShareError("UPLOAD_REFUSED", `Starting the ${label} upload returned no usable session.`);
183
+
184
+ let offset = 0;
185
+ let attempts = 0;
186
+ const handle = fs.openSync(filePath, "r");
187
+ const buffer = Buffer.alloc(Math.min(chunkBytes, total)); // one buffer, reused for every chunk
188
+ try {
189
+ while (offset < total) {
190
+ const end = Math.min(offset + chunkBytes, total) - 1;
191
+ const length = end - offset + 1;
192
+ fs.readSync(handle, buffer, 0, length, offset);
193
+ const chunk = buffer.subarray(0, length);
194
+ let response;
195
+ try {
196
+ response = await fetchImpl(session, {
197
+ method: "PUT",
198
+ headers: { "content-range": `bytes ${offset}-${end}/${total}` },
199
+ body: chunk,
200
+ redirect: "manual",
201
+ });
202
+ } catch (error) {
203
+ if (++attempts >= MAX_ATTEMPTS_PER_CHUNK) throw new ShareError("UPLOAD_FAILED", `The ${label} upload kept failing: ${error.message}`);
204
+ offset = await committedBytes(fetchImpl, session, total);
205
+ continue;
206
+ }
207
+ rejectRedirect(response, label);
208
+ if (response.status === 200 || response.status === 201) return { bytes: total };
209
+ if (response.status === 308) {
210
+ // Progress resets the retry count; a 308 whose committed range did not move counts as a failed
211
+ // attempt, so a session that never advances ends instead of looping forever.
212
+ const next = rangeEnd(response.headers.get("range"));
213
+ if (next > offset) attempts = 0;
214
+ else if (++attempts >= MAX_ATTEMPTS_PER_CHUNK) throw new ShareError("UPLOAD_FAILED", `The ${label} upload stopped moving: storage kept the same ${next} bytes.`);
215
+ offset = next;
216
+ continue;
217
+ }
218
+ if (response.status >= 500 && ++attempts < MAX_ATTEMPTS_PER_CHUNK) {
219
+ offset = await committedBytes(fetchImpl, session, total);
220
+ continue;
221
+ }
222
+ throw new ShareError("UPLOAD_FAILED", `The ${label} upload failed: ${await describe(response)}.`);
223
+ }
224
+ } finally {
225
+ fs.closeSync(handle);
226
+ }
227
+ throw new ShareError("UPLOAD_FAILED", `The ${label} upload did not finish.`);
228
+ }
229
+
230
+ async function committedBytes(fetchImpl, session, total) {
231
+ const status = rejectRedirect(await fetchImpl(session, { method: "PUT", headers: { "content-range": `bytes */${total}` }, body: "", redirect: "manual" }), "file");
232
+ if (status.status === 200 || status.status === 201) return total;
233
+ return rangeEnd(status.headers.get("range"));
234
+ }
235
+
236
+ /** A 3xx with a Location is a redirect away from storage: refused. (GCS's 308 Resume Incomplete has none.) */
237
+ function rejectRedirect(response, label) {
238
+ if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
239
+ throw new ShareError("UPLOAD_REFUSED", `The ${label} upload was redirected; uploads go only to Foldspace's video storage.`);
240
+ }
241
+ return response;
242
+ }
243
+
244
+ /** "bytes=0-1048575" → 1048576 (the next byte to send); no header → 0. */
245
+ function rangeEnd(header) {
246
+ const match = /bytes=0-(\d+)/.exec(header || "");
247
+ return match ? Number(match[1]) + 1 : 0;
248
+ }
249
+
250
+ async function describe(response) {
251
+ let code = "";
252
+ try {
253
+ const text = await response.text();
254
+ code = /<Code>([^<]{1,64})<\/Code>/.exec(text)?.[1] ?? "";
255
+ } catch {
256
+ // no body
257
+ }
258
+ return `HTTP ${response.status}${code ? ` ${code}` : ""}`;
259
+ }
260
+
261
+ function statFile(file, code, label) {
262
+ try {
263
+ const stat = fs.statSync(file);
264
+ if (!stat.isFile()) throw new Error("not a file");
265
+ return { path: file, bytes: stat.size };
266
+ } catch {
267
+ throw new ShareError(code, `No ${label} at ${file}.`);
268
+ }
269
+ }
270
+
271
+ function readSlice(file, offset, length) {
272
+ const handle = fs.openSync(file, "r");
273
+ try {
274
+ const buffer = Buffer.alloc(length);
275
+ const read = fs.readSync(handle, buffer, 0, length, offset);
276
+ return buffer.subarray(0, read);
277
+ } finally {
278
+ fs.closeSync(handle);
279
+ }
280
+ }
281
+
282
+ function mb(bytes) {
283
+ return Math.round(bytes / (1024 * 1024));
284
+ }
@@ -2,9 +2,23 @@ export const AGENT_API_NAME = {{AGENT_API_NAME_JSON}};
2
2
  export const PRODUCT_ID = {{PRODUCT_ID_JSON}};
3
3
  export const APP_DOMAIN = {{APP_DOMAIN_JSON}};
4
4
 
5
+ /**
6
+ * Whether each of the product's customers has their own subdomain
7
+ * (acme.example.com, globex.example.com). Left undecided until the human has
8
+ * answered: `true` or `false` is their answer, never a guess.
9
+ */
10
+ export const PER_CUSTOMER_SUBDOMAIN: boolean | undefined = undefined;
11
+
5
12
  /**
6
13
  * Empty until you capture the app's own XHR. A guessed host looks like it
7
- * worked. Include an explicit port if the app uses one.
14
+ * worked. One of:
15
+ * - `"https://api.example.com"` - every customer calls the same backend.
16
+ * Include an explicit port if the app uses one.
17
+ * - `"/api"` - the backend is on the page's own host.
18
+ * - `"https://{subdomain}.api.example.com"` - each customer's backend carries
19
+ * their subdomain; the runtime puts each user's own in.
20
+ * With `PER_CUSTOMER_SUBDOMAIN = true`, never the address you observed as it
21
+ * is: that is one customer's backend, and every user's agent would call it.
8
22
  */
9
23
  export const API_BASE = "";
10
24
 
@@ -14,6 +14,9 @@
14
14
  * - `apiFetch` - JSON + `Authorization: Bearer`. For any other sign-in, see
15
15
  * "Custom auth" below.
16
16
  * - `apiFetchBinary` — same auth, raw bytes (file download).
17
+ * - `apiBaseUrl` — `API_BASE` for this page: `"/api"` on the page's own host,
18
+ * `{subdomain}` filled with this user's own. A custom `apiFetch` builds its
19
+ * URLs from it, never from `API_BASE` directly.
17
20
  * - `publicFetch` — no token; marketing / public origin only.
18
21
  * - `getAuthToken` / `parseJwt` / `redact` — session read and safe logging.
19
22
  * - `mapWithConcurrency` — cap parallel fetches over a list.
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Written by foldspace init: the commands a build runs, allowed up front so the coding agent is not stopped one denial at a time. init and upgrade are left out on purpose, so they ask, like anything not listed. foldspace deploy is Foldspace-internal and never runs in a build.",
2
+ "$comment": "Written by foldspace init: the commands a build runs, allowed up front so the coding agent is not stopped one denial at a time. init and upgrade are left out on purpose, so they ask, like anything not listed; so is share upload, which sends a video out. foldspace deploy is Foldspace-internal and never runs in a build.",
3
3
  "permissions": {
4
4
  "allow": [
5
5
  "Bash(npx foldspace help:*)",
@@ -12,6 +12,8 @@
12
12
  "Bash(npx foldspace badge:*)",
13
13
  "Bash(npx foldspace published:*)",
14
14
  "Bash(npx foldspace record:*)",
15
+ "Bash(npx foldspace share inspect:*)",
16
+ "Bash(npx foldspace sync:*)",
15
17
  "Bash(npm install:*)",
16
18
  "Bash(npm run build:*)",
17
19
  "Bash(npm run typecheck:*)",
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: review-experience
3
+ description: Check an experience's code before every save of the agent's code - it returns only the data the answer needs, every write waits for the user's yes, and it reaches nothing the user cannot reach in the product. A serious finding stops the save. Use before each repo_write of agent code, after `npx foldspace build` passed - not for a save that only brings agent/runtime/ up to date.
4
+ ---
5
+
6
+ # Review an experience before it is saved
7
+
8
+ Saving puts the code where it can be published for every user of the
9
+ product - each with their own data, their own role and their own sign-in.
10
+ What was right for the builder's account in the test window has to be
11
+ right for all of them. This review is the last look before that.
12
+
13
+ `npx foldspace build` already checked what can be read off the code: one
14
+ customer's address written in, the user's token leaving the page,
15
+ secrets, calls to hosts the project does not name. Those are not repeated
16
+ here, and cards may draw any markup they need. This review is the three things only
17
+ someone who knows what the experience is for can judge.
18
+
19
+ ## When
20
+
21
+ Before **every** save of agent code with `repo_write` - the first one and
22
+ each one after. Run it on the experiences the save changes; a save of only
23
+ the files a build put in `agent/runtime/` needs no review.
24
+
25
+ Build first: a save waits for `npx foldspace build` with no errors, then
26
+ this review.
27
+
28
+ ## 1. Least data
29
+
30
+ What `execute` returns reaches the agent and the model, and for a view card
31
+ it is what the agent answers from (rule 2 of the manual). Return the facts
32
+ the answer needs, picked one field at a time - nothing else.
33
+
34
+ Read each `execute` and each card `callback` and check:
35
+
36
+ - **Fields are picked by name.** Never `res.data`, a whole record, or
37
+ `...record` from the API.
38
+ *"Which invoices are due?"* needs invoice number, amount, due date and
39
+ status - not the customer record they came with (address, phone, tax id,
40
+ notes, the account manager).
41
+ - **Only the ids the card or the next action uses.** An internal id nothing
42
+ reads is data for nothing.
43
+ - **Other people's details only when the question is about them.** A list of
44
+ deals needs the deal owner's name, not their email and phone.
45
+ - **Rows the card shows, not the whole table.** Cap the list at what the card
46
+ draws (and say how many there are in all, if the answer needs it).
47
+
48
+ **Serious:** a whole record or the raw response returned; personal details
49
+ (email, phone, address, date of birth, payment details, government ids) the
50
+ answer does not need. **Fix now, not serious:** an extra harmless field.
51
+
52
+ ## 2. A yes before every write
53
+
54
+ The agent acts with the user's own sign-in: a write is the user writing.
55
+ It happens only after the user said yes to exactly that change.
56
+
57
+ Find every write in the experience: an `apiFetch` (or `fetch`) with
58
+ `method` `POST`, `PUT`, `PATCH` or `DELETE`, a form post (`submitForm`), and
59
+ any helper in `agent/api/` that calls one. For each:
60
+
61
+ Every write gets the user's yes in one of two ways:
62
+
63
+ - **A card (visual widget), on the user's press.** The card shows what will
64
+ change before they press - `recipes/create-a-record` has the shape.
65
+ *Cancel the Pro plan* is a card that says *"Cancel your Pro plan on 1
66
+ November? You keep it until then."* with **Cancel plan** and **Keep it**;
67
+ nothing is sent until **Cancel plan** is pressed, and **Keep it** or closing
68
+ the card writes nothing.
69
+ - **Text only (no card): the action is marked confirmation required.** Set
70
+ `confirmation_required: true` and a short `confirmation_message` on the
71
+ action (the Foldspace action tools take both; in Agent Studio it is the
72
+ action's *confirmation required* setting). Foldspace then asks the user
73
+ before `execute` runs.
74
+
75
+ - **It writes what the user saw** - the same record, the same values - and
76
+ nothing besides: no "mark as read", no second record on the side.
77
+ - **It is never repeated by itself.** A write that timed out may have landed:
78
+ say so, as `recipes/update-meeting-notes` does, rather than sending it again.
79
+
80
+ ⚠️ One case may need neither - this is a reading of
81
+ `recipes/update-meeting-notes`, not yet a confirmed rule: **adding one new
82
+ thing whose words the user gave in the same message** (*"add this to the
83
+ meeting notes: ..."*). Their message is the yes, as long as `execute` writes
84
+ exactly what they said and returns what was written. When in doubt, a card or confirmation required.
85
+
86
+ **Serious:** a write with neither a card nor confirmation required that removes, cancels, replaces or
87
+ overwrites something; sends something to someone (an email, an invite, a
88
+ message); spends or refunds money or changes a plan; changes who can see or
89
+ do what; or touches more than one record. A write the user did not ask for.
90
+
91
+ ## 3. Nothing the user cannot reach in the product
92
+
93
+ The experience may reach only what this user could reach in the product
94
+ itself. The builder is often signed in as an owner or an admin; most users
95
+ are not.
96
+
97
+ - **Every call is one the product's own screens make**, seen with
98
+ `npx foldspace observe screen` on a screen `observe menu` lists - not a path
99
+ found in the app's code, in its API docs, guessed, or reached by typing an
100
+ address. `docs/app-profile.md` says how each endpoint was established.
101
+ - **A screen only an admin sees stays an admin's.** When the builder's
102
+ account can see admin screens (Users, Billing, Settings for the whole
103
+ account, anything under `/admin`), ask the human once, as a choice:
104
+ *"Do all your users see <the screen>, or only admins?"* Only admins: this
105
+ experience is not for everyone - say so, and do not save it as one.
106
+ - **Nothing widens the reach.** No `all=true`, `scope=account`,
107
+ `includeDeleted`, export-everything or impersonation calls. A user's,
108
+ team's or account's id comes from the session (`/me`,
109
+ `recipes/who-is-the-user`) or from an earlier action's result - never from
110
+ the user's or the model's words.
111
+ - **Do not rely on the screen being hidden.** Some products hide a screen and
112
+ still answer its call for anyone signed in. The test is what the product
113
+ shows this kind of user, not what its backend lets through.
114
+
115
+ **Serious:** a call to an endpoint no screen for this kind of user makes; an
116
+ admin-only screen in an experience for everyone; an id that widens the reach
117
+ taken from words.
118
+
119
+ ## 4. What happens next
120
+
121
+ - **Nothing serious:** save. In chat, one line at most - *"Checked it before
122
+ saving."* Fix the not-serious ones in the same pass, before the save.
123
+ - **Something serious:** do not save. It is your code: fix it - pick the
124
+ fields, move the write behind a card, drop the call - build again, and run
125
+ this review again. Then save.
126
+ - **A serious finding only the human can settle** (whether regular users see
127
+ a screen, whether a write needs a card): ask as a choice, with the safe
128
+ option first, and save only after the answer settles it. Their answer goes
129
+ in `docs/app-profile.md`, with the date.
130
+ - **Never save around it** - not "for now", not to dev only: dev is where their
131
+ users can be sent next.
132
+
133
+ Nothing here is said to the human in these words: in chat it is *"it only
134
+ shows the invoice details, not the whole customer record"*, *"cancelling
135
+ asks the user first"* - what their users will see.
@@ -22,7 +22,8 @@ nothing to set.
22
22
 
23
23
  - **Real data from their account.** The signed-in app as it is: their records,
24
24
  their numbers, their name wherever the app or the panel shows it. Nothing is
25
- blurred, nothing is made up, nothing is uploaded.
25
+ blurred, nothing is made up, and nothing is uploaded unless the human asks
26
+ for a share link (step 7).
26
27
  - **Sharing it is their call** - whether, and where. Like a screen recording
27
28
  they made themselves.
28
29
  - Never create demo data for the video, never blur or crop parts of it, and
@@ -181,7 +182,9 @@ If `ok` is false, find its `code` in the table below. If true, read the poster
181
182
  ended: each answer complete, none an error or "that action isn't available",
182
183
  and each one shows what its capabilities and line say. A bad one: offer
183
184
  another question for that use case, prove it, record again. If there is a
184
- `note` that it ran long, offer to drop the last scene.
185
+ `note` that it ran long, offer to drop the last scene. If there is a
186
+ `wideWindow` note, tell the human in one line and offer a narrower test window
187
+ and a new recording; they decide.
185
188
 
186
189
  ## 6. Hand it over
187
190
 
@@ -199,8 +202,48 @@ another question for that use case, prove it, record again. If there is a
199
202
  goes is their call.
200
203
 
201
204
  **Sharing:** upload the MP4 itself to LinkedIn or X - that is also how it
202
- plays inline in the feed. A Foldspace share link (a page anyone can open to
203
- watch it) is coming; until then, the file.
205
+ plays inline in the feed. Or a Foldspace share link (step 7): a page anyone
206
+ with the link can open to watch it, with a "Build yours" button, and an email
207
+ to them when it is watched.
208
+
209
+ ## 7. A Foldspace share link - only when they ask for one
210
+
211
+ Offer it as one option next to "send me the file", never as the default.
212
+ The link is public: anyone who has it can watch, with no sign-in. So before
213
+ anything leaves this computer, ask once, as a choice: *"A share link is public
214
+ to anyone who has it, and the video shows [what is on screen]. Create one?"*
215
+ Only on a yes:
216
+
217
+ 1. `npx foldspace share inspect` (the newest `video/*.mp4` and its poster, or
218
+ pass the path). It uploads nothing. If it says the MP4 is not fast-start,
219
+ that file did not come from `record`: say so and stop.
220
+ 2. The Foldspace tool `create_video_share` with a title in their words (the
221
+ product and what the agent does, up to 200 characters), and `video_bytes`
222
+ and `poster_bytes` from step 1.
223
+ 3. Run the upload command it returns, exactly - it carries one-time upload
224
+ targets for 30 minutes. Pass `upload` the same video path and `--poster`
225
+ you gave `inspect`, if any, so it uploads the files `inspect` sized. Never
226
+ paste the targets into chat, a file or a commit. In Codex (or any editor
227
+ that runs commands in a sandbox), this command needs the network: run it
228
+ outside the sandbox through the editor's own approval, with one plain line
229
+ on why.
230
+ 4. When it prints `"status": "uploaded"`, the tool `publish_video_share` with
231
+ the `share_id`. It returns the link: give it to them as it is.
232
+ 5. Tell them in one line that they get an email at the first view and a daily
233
+ summary on days it is watched, and that you can show the numbers
234
+ (`get_video_share_stats`) or delete the link (`delete_video_share`) whenever
235
+ they ask. `publish_video_share` says whether those emails are on for this
236
+ share.
237
+ 6. They want the emails off, or back on: the tool
238
+ `set_video_share_notifications` with the `share_id` and
239
+ `email_notifications` (`false` stops them, `true` turns them on). It covers
240
+ that one video only.
241
+
242
+ The tools refuse with a reason you pass on as it is: an EU account (share
243
+ links are not offered there yet - send the file instead), a cap (shares per
244
+ day, or a trial's monthly limit), or a sign-in by API key rather than as a
245
+ person. The CLI never signs in to Foldspace and never reaches the Foldspace
246
+ tools itself: steps 2 and 4 are tool calls you make.
204
247
 
205
248
  The video holds real data, so it is never saved with the code: `record` writes
206
249
  `video/.gitignore` (`*.mp4`, `*.jpg`) in every project. `video/scenes.json`