@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.
- package/CLAUDE.md +169 -49
- package/README.md +30 -2
- package/bin/inject.mjs +2 -18
- package/bin/observe.mjs +18 -0
- package/bin/record.mjs +2 -0
- package/bin/share.mjs +63 -0
- package/bin/sync.mjs +48 -0
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -1
- package/recipes/bottom-bar/README.md +24 -11
- package/recipes/bottom-bar/agent/bottomBar.ts +76 -38
- package/recipes/bottom-bar/fixtures/configuration.sent.json +13 -12
- package/recipes/swap-the-login-method/README.md +1 -1
- package/recipes/swap-the-login-method/agent/utils.ts +8 -7
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +8 -7
- package/src/chrome-binary.mjs +33 -0
- package/src/cli-registry.mjs +96 -5
- package/src/customer-subdomain.mjs +149 -0
- package/src/lint/index.mjs +23 -3
- package/src/lint/rules.mjs +46 -2
- package/src/lint/safety.mjs +785 -0
- package/src/observe-core.mjs +1 -1
- package/src/runtime/VERSION +1 -1
- package/src/runtime/config.ts +15 -2
- package/src/runtime/http.ts +40 -3
- package/src/runtime/index.ts +1 -0
- package/src/sync-core.mjs +374 -0
- package/src/upgrade.mjs +36 -7
- package/src/video/compose.mjs +12 -3
- package/src/video/recorder.mjs +15 -7
- package/src/video/share.mjs +284 -0
- package/templates/agent-starter/agent/constants.ts +15 -1
- package/templates/agent-starter/agent/utils.ts +3 -0
- package/templates/agent-starter/claude-settings.json +3 -1
- package/templates/agent-starter/skills/review-experience/SKILL.md +135 -0
- 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.
|
|
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.
|
|
203
|
-
watch it
|
|
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`
|