@foldspace_npm/harness 0.1.1 → 0.1.3

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/src/init.mjs CHANGED
@@ -1,14 +1,18 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
3
6
  import { fileURLToPath } from "node:url";
7
+ import { commandByName } from "./cli-registry.mjs";
4
8
 
5
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
6
10
  const defaultTemplateRoot = path.join(packageRoot, "templates", "agent-starter");
7
11
  const allowedFlags = new Set(["name", "product-id", "agent-api-name", "domain"]);
8
12
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
13
+ const defaultDirectory = "my-agent";
9
14
 
10
- export const initUsage =
11
- "foldspace init <directory> --product-id <id> --agent-api-name <name> --domain <host> [--name <display-name>]";
15
+ export const initUsage = commandByName("init").usage;
12
16
 
13
17
  function assertSupportedNode() {
14
18
  const major = Number.parseInt(process.versions.node.split(".", 1)[0], 10);
@@ -17,6 +21,13 @@ function assertSupportedNode() {
17
21
  }
18
22
  }
19
23
 
24
+ function isInteractive(options = {}) {
25
+ if (typeof options.interactive === "boolean") {
26
+ return options.interactive;
27
+ }
28
+ return Boolean(options.stdin?.isTTY ?? input.isTTY);
29
+ }
30
+
20
31
  function readFlag(argv, index) {
21
32
  const argument = argv[index];
22
33
  const equalsIndex = argument.indexOf("=");
@@ -61,25 +72,28 @@ export function parseInitArgs(argv) {
61
72
  index += 1;
62
73
  }
63
74
 
64
- if (positional.length !== 1) {
75
+ if (positional.length > 1) {
65
76
  throw new Error(`Usage: ${initUsage}`);
66
77
  }
67
78
 
68
- for (const required of ["product-id", "agent-api-name", "domain"]) {
69
- if (!flags[required]) {
70
- throw new Error(`Missing required option: --${required}\nUsage: ${initUsage}`);
71
- }
72
- }
73
-
74
79
  return {
75
- targetDir: path.resolve(positional[0]),
76
- displayName: flags.name || path.basename(path.resolve(positional[0])),
77
- productId: flags["product-id"],
78
- agentApiName: flags["agent-api-name"],
79
- domain: flags.domain,
80
+ directory: positional[0] || null,
81
+ displayName: flags.name || null,
82
+ productId: flags["product-id"] || null,
83
+ agentApiName: flags["agent-api-name"] || null,
84
+ domain: flags.domain || null,
80
85
  };
81
86
  }
82
87
 
88
+ function missingInitFields(parsed) {
89
+ const missing = [];
90
+ if (!parsed.directory) missing.push("directory");
91
+ if (!parsed.productId) missing.push("product-id");
92
+ if (!parsed.agentApiName) missing.push("agent-api-name");
93
+ if (!parsed.domain) missing.push("domain");
94
+ return missing;
95
+ }
96
+
83
97
  function toPackageName(value) {
84
98
  return value
85
99
  .trim()
@@ -142,6 +156,111 @@ function normalizeTarget(value) {
142
156
  };
143
157
  }
144
158
 
159
+ async function ask(question, options = {}) {
160
+ if (typeof options.ask === "function") {
161
+ return options.ask(question);
162
+ }
163
+
164
+ const rl = readline.createInterface({
165
+ input: options.stdin || input,
166
+ output: options.stdout || output,
167
+ });
168
+ try {
169
+ return (await rl.question(question)).trim();
170
+ } finally {
171
+ rl.close();
172
+ }
173
+ }
174
+
175
+ async function askRequired(label, options = {}) {
176
+ while (true) {
177
+ const value = await ask(`${label}: `, options);
178
+ if (value) {
179
+ return value;
180
+ }
181
+ (options.log || console.log)(`${label} is required.`);
182
+ }
183
+ }
184
+
185
+ async function askUntilValid(label, validate, options = {}) {
186
+ while (true) {
187
+ const value = await askRequired(label, options);
188
+ try {
189
+ return validate(value);
190
+ } catch (error) {
191
+ (options.log || console.log)(error instanceof Error ? error.message : String(error));
192
+ }
193
+ }
194
+ }
195
+
196
+ async function promptForMissingFields(parsed, options = {}) {
197
+ const next = { ...parsed };
198
+ const log = options.log || console.log;
199
+
200
+ if (!next.directory) {
201
+ const value = await ask(`Project directory [${defaultDirectory}]: `, options);
202
+ next.directory = value || defaultDirectory;
203
+ }
204
+
205
+ if (!next.productId) {
206
+ next.productId = await askUntilValid("Product ID", validateProductId, options);
207
+ }
208
+
209
+ if (!next.agentApiName) {
210
+ next.agentApiName = await askUntilValid(
211
+ "Agent API name",
212
+ (value) => validateIdentifier(value, "Agent API name"),
213
+ options,
214
+ );
215
+ }
216
+
217
+ if (!next.domain) {
218
+ next.domain = await askUntilValid(
219
+ "Domain",
220
+ (value) => {
221
+ normalizeTarget(value);
222
+ return value.trim();
223
+ },
224
+ options,
225
+ );
226
+ }
227
+
228
+ if (!next.displayName) {
229
+ const defaultName = path.basename(path.resolve(next.directory));
230
+ const value = await ask(`Display name [${defaultName}]: `, options);
231
+ next.displayName = value || defaultName;
232
+ }
233
+
234
+ log("");
235
+ return next;
236
+ }
237
+
238
+ function finalizeInitConfig(parsed) {
239
+ const directory = parsed.directory;
240
+ if (!directory) {
241
+ throw new Error(`Missing required option: directory\nUsage: ${initUsage}`);
242
+ }
243
+
244
+ for (const [key, label] of [
245
+ ["productId", "product-id"],
246
+ ["agentApiName", "agent-api-name"],
247
+ ["domain", "domain"],
248
+ ]) {
249
+ if (!parsed[key]) {
250
+ throw new Error(`Missing required option: --${label}\nUsage: ${initUsage}`);
251
+ }
252
+ }
253
+
254
+ const targetDir = path.resolve(directory);
255
+ return {
256
+ targetDir,
257
+ displayName: parsed.displayName || path.basename(targetDir),
258
+ productId: parsed.productId,
259
+ agentApiName: parsed.agentApiName,
260
+ domain: parsed.domain,
261
+ };
262
+ }
263
+
145
264
  function readHarnessVersion(root) {
146
265
  const manifestPath = path.join(root, "package.json");
147
266
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -281,21 +400,76 @@ export function scaffoldProject(config, options = {}) {
281
400
  };
282
401
  }
283
402
 
284
- export function runInit(argv, options = {}) {
403
+ function runInstall(targetDir, options = {}) {
404
+ const install = options.install || ((cwd) => {
405
+ execFileSync("npm", ["install", "--ignore-scripts"], {
406
+ cwd,
407
+ stdio: "inherit",
408
+ env: process.env,
409
+ });
410
+ });
411
+ install(targetDir);
412
+ }
413
+
414
+ function isAffirmative(value) {
415
+ const normalized = value.trim().toLowerCase();
416
+ return normalized === "" || normalized === "y" || normalized === "yes";
417
+ }
418
+
419
+ async function maybeInstallDependencies(targetDir, options = {}) {
420
+ if (!isInteractive(options)) {
421
+ return false;
422
+ }
423
+
424
+ if (options.autoInstall === true) {
425
+ runInstall(targetDir, options);
426
+ return true;
427
+ }
428
+
429
+ if (options.autoInstall === false) {
430
+ return false;
431
+ }
432
+
433
+ const answer = await ask("Run npm install --ignore-scripts now? [Y/n] ", options);
434
+ if (!isAffirmative(answer)) {
435
+ return false;
436
+ }
437
+
438
+ runInstall(targetDir, options);
439
+ return true;
440
+ }
441
+
442
+ export async function runInit(argv, options = {}) {
285
443
  assertSupportedNode();
286
- const config = parseInitArgs(argv);
444
+ let parsed = parseInitArgs(argv);
445
+ const missing = missingInitFields(parsed);
446
+
447
+ if (missing.length > 0) {
448
+ if (!isInteractive(options)) {
449
+ const label = missing[0] === "directory" ? "directory" : `--${missing[0]}`;
450
+ throw new Error(`Missing required option: ${label}\nUsage: ${initUsage}`);
451
+ }
452
+ parsed = await promptForMissingFields(parsed, options);
453
+ } else if (!parsed.displayName) {
454
+ parsed.displayName = path.basename(path.resolve(parsed.directory));
455
+ }
456
+
457
+ const config = finalizeInitConfig(parsed);
287
458
  const result = scaffoldProject(config, options);
288
459
  const log = options.log || console.log;
460
+ const installed = await maybeInstallDependencies(result.targetDir, options);
289
461
 
290
462
  log(`Created Foldspace project at ${result.targetDir}`);
291
463
  log(`Using @foldspace_npm/harness ${result.harnessVersion}`);
292
464
  log("");
293
465
  log("Next steps:");
294
466
  log(` cd ${result.targetDir}`);
295
- log(" npm install --ignore-scripts");
467
+ if (!installed) {
468
+ log(" npm install --ignore-scripts");
469
+ }
296
470
  log(" npm run build");
297
471
  log(" npm run inject");
298
472
  log(" npm run attach");
299
473
 
300
- return result;
474
+ return { ...result, installed };
301
475
  }
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function readProjectConfig(projectDir) {
5
+ const configPath = path.join(projectDir, "foldspace.dev.json");
6
+ if (!fs.existsSync(configPath)) {
7
+ throw new Error("foldspace.dev.json not found");
8
+ }
9
+ let config;
10
+ try {
11
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
12
+ } catch (error) {
13
+ throw new Error(
14
+ `foldspace.dev.json is invalid: ${
15
+ error instanceof Error ? error.message : String(error)
16
+ }`,
17
+ );
18
+ }
19
+ return config;
20
+ }
21
+
22
+ export function resolveConfiguredTarget(config, requestedTarget) {
23
+ const targetName = requestedTarget || config?.defaultTarget;
24
+ if (typeof targetName !== "string" || !targetName) {
25
+ throw new Error("foldspace.dev.json must define defaultTarget");
26
+ }
27
+ const target = config?.targets?.[targetName];
28
+ if (!target || typeof target !== "object") {
29
+ throw new Error(`target "${targetName}" is not defined`);
30
+ }
31
+ for (const field of ["productId", "agentApiName"]) {
32
+ if (typeof target[field] !== "string" || !target[field]) {
33
+ throw new Error(`target "${targetName}" must define ${field}`);
34
+ }
35
+ }
36
+ return { targetName, target };
37
+ }
@@ -0,0 +1,181 @@
1
+ // Browser-safe request/result contracts shared by local CDP and future
2
+ // extension adapters. This module describes evidence; it does not inspect a
3
+ // page or select a transport.
4
+ export const HARNESS_PROTOCOL_VERSION = 1;
5
+
6
+ export const CAPABILITIES = Object.freeze({
7
+ PAGE_EVALUATE: "page.evaluate",
8
+ PAGE_MUTATE: "page.mutate",
9
+ ARTIFACT_LOAD: "artifact.load",
10
+ CDP: "browser.cdp",
11
+ });
12
+
13
+ export const ERROR_CODES = Object.freeze({
14
+ INVALID_REQUEST: "INVALID_REQUEST",
15
+ CAPABILITY_DISABLED: "CAPABILITY_DISABLED",
16
+ SDK_NOT_FOUND: "SDK_NOT_FOUND",
17
+ AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
18
+ REGISTRATION_MISMATCH: "REGISTRATION_MISMATCH",
19
+ DIAGNOSTIC_FAILED: "DIAGNOSTIC_FAILED",
20
+ TIMEOUT: "TIMEOUT",
21
+ EVIDENCE_LIMIT: "EVIDENCE_LIMIT",
22
+ TRANSPORT_ERROR: "TRANSPORT_ERROR",
23
+ });
24
+
25
+ export const LIFECYCLE_OPERATIONS = Object.freeze([
26
+ "prepare_page",
27
+ "load_artifact",
28
+ "inspect_registration",
29
+ "run_diagnostic",
30
+ "cleanup",
31
+ ]);
32
+
33
+ export const PROTOCOL_LIMITS = Object.freeze({
34
+ maxRequestBytes: 16 * 1024,
35
+ maxResultBytes: 64 * 1024,
36
+ maxIdentifierLength: 128,
37
+ });
38
+
39
+ const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
40
+
41
+ function isRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ function jsonByteLength(value, label) {
46
+ let json;
47
+ try {
48
+ json = JSON.stringify(value);
49
+ } catch {
50
+ throw new TypeError(`${label} must be JSON serializable`);
51
+ }
52
+ if (json === undefined) {
53
+ throw new TypeError(`${label} must be JSON serializable`);
54
+ }
55
+ return new TextEncoder().encode(json).length;
56
+ }
57
+
58
+ function assertIdentifier(value, label) {
59
+ if (
60
+ typeof value !== "string" ||
61
+ value.length === 0 ||
62
+ value.length > PROTOCOL_LIMITS.maxIdentifierLength ||
63
+ !identifierPattern.test(value)
64
+ ) {
65
+ throw new TypeError(
66
+ `${label} must be a non-empty identifier of at most ${PROTOCOL_LIMITS.maxIdentifierLength} characters`,
67
+ );
68
+ }
69
+ }
70
+
71
+ export function createDiagnosticRequest({
72
+ id,
73
+ name,
74
+ args = {},
75
+ timeoutMs = 10_000,
76
+ }) {
77
+ assertIdentifier(id, "Diagnostic request id");
78
+ assertIdentifier(name, "Diagnostic name");
79
+ if (!isRecord(args)) {
80
+ throw new TypeError("Diagnostic args must be an object");
81
+ }
82
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
83
+ throw new TypeError("Diagnostic timeoutMs must be an integer between 1 and 60000");
84
+ }
85
+
86
+ const request = {
87
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
88
+ id,
89
+ name,
90
+ args,
91
+ timeoutMs,
92
+ };
93
+ if (jsonByteLength(request, "Diagnostic request") > PROTOCOL_LIMITS.maxRequestBytes) {
94
+ throw new RangeError("Diagnostic request exceeds the protocol size limit");
95
+ }
96
+ return request;
97
+ }
98
+
99
+ export function diagnosticSuccess(request, result, metadata = {}) {
100
+ if (!isRecord(metadata)) {
101
+ throw new TypeError("Diagnostic metadata must be an object");
102
+ }
103
+ const response = {
104
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
105
+ id: request.id,
106
+ name: request.name,
107
+ ok: true,
108
+ result,
109
+ metadata,
110
+ };
111
+ if (jsonByteLength(response, "Diagnostic result") > PROTOCOL_LIMITS.maxResultBytes) {
112
+ return diagnosticFailure(request, {
113
+ code: ERROR_CODES.EVIDENCE_LIMIT,
114
+ message: "Diagnostic result exceeded the protocol size limit",
115
+ });
116
+ }
117
+ return response;
118
+ }
119
+
120
+ export function diagnosticFailure(request, error) {
121
+ const code = Object.values(ERROR_CODES).includes(error?.code)
122
+ ? error.code
123
+ : ERROR_CODES.DIAGNOSTIC_FAILED;
124
+ const message =
125
+ typeof error?.message === "string" && error.message.trim()
126
+ ? error.message.slice(0, 2_000)
127
+ : "Diagnostic failed";
128
+
129
+ return {
130
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
131
+ id: request.id,
132
+ name: request.name,
133
+ ok: false,
134
+ error: { code, message },
135
+ };
136
+ }
137
+
138
+ export function createLifecycleResult({
139
+ operation,
140
+ ok,
141
+ state,
142
+ error,
143
+ details = {},
144
+ }) {
145
+ if (!LIFECYCLE_OPERATIONS.includes(operation)) {
146
+ throw new TypeError(`Unknown lifecycle operation: ${operation}`);
147
+ }
148
+ if (typeof ok !== "boolean") {
149
+ throw new TypeError("Lifecycle ok must be a boolean");
150
+ }
151
+ assertIdentifier(state, "Lifecycle state");
152
+ if (!isRecord(details)) {
153
+ throw new TypeError("Lifecycle details must be an object");
154
+ }
155
+
156
+ const result = {
157
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
158
+ operation,
159
+ ok,
160
+ state,
161
+ details,
162
+ ...(ok
163
+ ? {}
164
+ : {
165
+ error: {
166
+ code: Object.values(ERROR_CODES).includes(error?.code)
167
+ ? error.code
168
+ : ERROR_CODES.TRANSPORT_ERROR,
169
+ message:
170
+ typeof error?.message === "string" && error.message.trim()
171
+ ? error.message.slice(0, 2_000)
172
+ : "Lifecycle operation failed",
173
+ },
174
+ }),
175
+ };
176
+
177
+ if (jsonByteLength(result, "Lifecycle result") > PROTOCOL_LIMITS.maxResultBytes) {
178
+ throw new RangeError("Lifecycle result exceeds the protocol size limit");
179
+ }
180
+ return result;
181
+ }
@@ -0,0 +1,133 @@
1
+ import { ACTION_LOG_PREFIX } from "./action-observer.mjs";
2
+
3
+ function normalizeConsoleText(value) {
4
+ return String(value ?? "")
5
+ .replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "<id>")
6
+ .replace(/\b(?:bearer\s+)?[A-Za-z0-9_-]{40,}\b/gi, "<redacted>")
7
+ .replace(/([?&](?:token|key|secret|code)=)[^&\s]+/gi, "$1<redacted>")
8
+ .replace(/\s+/g, " ")
9
+ .trim()
10
+ .slice(0, 1_000);
11
+ }
12
+
13
+ export function parseActionObservation(text) {
14
+ const value = String(text ?? "");
15
+ if (!value.startsWith(`${ACTION_LOG_PREFIX} `)) return null;
16
+ try {
17
+ const parsed = JSON.parse(value.slice(ACTION_LOG_PREFIX.length + 1));
18
+ return parsed && typeof parsed === "object" ? parsed : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function formatActionObservation(event) {
25
+ const action = event.actionName ? ` ${event.actionName}` : "";
26
+ const duration = Number.isFinite(event.durationMs)
27
+ ? ` ${event.durationMs}ms`
28
+ : "";
29
+ const keys =
30
+ Array.isArray(event.parameterKeys) && event.parameterKeys.length
31
+ ? ` keys=[${event.parameterKeys.join(",")}]`
32
+ : "";
33
+ if (event.phase === "registration") {
34
+ const count = Array.isArray(event.actionNames)
35
+ ? event.actionNames.length
36
+ : 0;
37
+ return `[actions] local artifact ${event.status} (${count} action${count === 1 ? "" : "s"})`;
38
+ }
39
+ return `[actions] ${event.source}:${event.phase}${action} ${event.status}${duration}${keys}`;
40
+ }
41
+
42
+ export function createSessionCollector({
43
+ maxErrorGroups = 50,
44
+ maxActionEvents = 200,
45
+ } = {}) {
46
+ const errorGroups = new Map();
47
+ const actionEvents = [];
48
+ let droppedErrorGroups = 0;
49
+ let droppedActionEvents = 0;
50
+
51
+ function recordConsole({ level = "log", text = "" } = {}) {
52
+ const action = parseActionObservation(text);
53
+ if (action) {
54
+ if (actionEvents.length >= maxActionEvents) {
55
+ actionEvents.shift();
56
+ droppedActionEvents += 1;
57
+ }
58
+ actionEvents.push(action);
59
+ return action;
60
+ }
61
+
62
+ const normalized = normalizeConsoleText(text);
63
+ const normalizedLevel = String(level).toLowerCase();
64
+ const isFailure =
65
+ normalizedLevel === "error" ||
66
+ normalizedLevel === "warning" ||
67
+ normalizedLevel === "warn" ||
68
+ /\b(?:error|failed|failure|status\s+[45]\d\d)\b/i.test(normalized);
69
+ if (!normalized || !isFailure) return null;
70
+
71
+ const key = `${normalizedLevel}:${normalized}`;
72
+ const existing = errorGroups.get(key);
73
+ if (existing) {
74
+ existing.count += 1;
75
+ } else if (errorGroups.size < maxErrorGroups) {
76
+ errorGroups.set(key, {
77
+ level: normalizedLevel,
78
+ text: normalized,
79
+ count: 1,
80
+ });
81
+ } else {
82
+ droppedErrorGroups += 1;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function snapshot() {
88
+ return {
89
+ actionEvents: actionEvents.map((event) => ({ ...event })),
90
+ errorGroups: Array.from(errorGroups.values()).map((group) => ({
91
+ ...group,
92
+ })),
93
+ droppedActionEvents,
94
+ droppedErrorGroups,
95
+ };
96
+ }
97
+
98
+ return { recordConsole, snapshot };
99
+ }
100
+
101
+ export function formatSessionSummary(snapshot) {
102
+ const lines = [];
103
+ if (snapshot.actionEvents.length) {
104
+ lines.push(
105
+ `Action observations (${snapshot.actionEvents.length}${
106
+ snapshot.droppedActionEvents
107
+ ? `, ${snapshot.droppedActionEvents} older dropped`
108
+ : ""
109
+ }):`,
110
+ );
111
+ for (const event of snapshot.actionEvents.slice(-20)) {
112
+ lines.push(` ${formatActionObservation(event)}`);
113
+ }
114
+ }
115
+ if (snapshot.errorGroups.length) {
116
+ lines.push(
117
+ `Page failures (${snapshot.errorGroups.length} group${
118
+ snapshot.errorGroups.length === 1 ? "" : "s"
119
+ }):`,
120
+ );
121
+ for (const group of snapshot.errorGroups) {
122
+ lines.push(
123
+ ` [${group.level} x${group.count}] ${group.text}`,
124
+ );
125
+ }
126
+ if (snapshot.droppedErrorGroups) {
127
+ lines.push(
128
+ ` ${snapshot.droppedErrorGroups} additional failure group(s) omitted`,
129
+ );
130
+ }
131
+ }
132
+ return lines.join("\n");
133
+ }
@@ -8,10 +8,13 @@ changes there.
8
8
 
9
9
  ## Before building
10
10
 
11
- 1. Connect Product MCP and verify that `list_agents` works.
12
- 2. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
11
+ 1. Run `npx foldspace help --json` before an unfamiliar harness operation.
12
+ Treat its risk, prerequisites, effects, and next-step fields as the current
13
+ CLI contract.
14
+ 2. Connect Product MCP and verify that `list_agents` works.
15
+ 3. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
13
16
  `list_task_agents`.
14
- 3. Use `discover_actions` to identify candidate experiences, then let the user
17
+ 4. Use `discover_actions` to identify candidate experiences, then let the user
15
18
  choose what to build.
16
19
 
17
20
  ## Build workflow
@@ -51,12 +54,18 @@ npm run attach
51
54
  ```
52
55
 
53
56
  `inject` launches an isolated Chrome profile and records its debug port.
54
- `attach` loads the SDK and the local `dist/index.js` bundle. Use
55
- `npm run attach -- --bootstrap` when the target page does not already embed the
56
- Foldspace SDK.
57
+ It does not generate or load an application extension. `attach` prepares the
58
+ page and loads the local `dist/index.js` bundle through CDP.
57
59
 
58
- The attach log must report `actions attached: N` before treating the page as
59
- running local action code.
60
+ Use the default swap only when the page already has the configured product and
61
+ agent. Use `--bootstrap` only when the page has no Foldspace SDK, and
62
+ `--replace` when it embeds a different product or agent, or when an SDK is
63
+ present without the configured agent.
64
+
65
+ The attach log must report `inspect_registration:registration_ok` before
66
+ treating the page as registered. Prove the experience through the visible agent
67
+ and require both an SDK action callback and local execute/render evidence; do
68
+ not invoke the handler directly.
60
69
 
61
70
  ## Verification gates
62
71
 
@@ -76,9 +85,23 @@ Do not report success without all six:
76
85
  - `agent/constants.ts` — agent, product, and domain identifiers
77
86
  - `agent/utils.ts` — Foldspace agent lookup
78
87
  - `foldspace.dev.json` — local harness target configuration
88
+ - `docs/` — optional coding-agent learnings; create on demand
79
89
 
80
90
  Do not introduce another bundler or bundle format.
81
91
 
92
+ ## Agent learnings
93
+
94
+ Use `docs/` to record durable, repository-specific lessons so later sessions do
95
+ not repeat the same mistakes.
96
+
97
+ - Before similar work, read any relevant notes already in `docs/`.
98
+ - After non-obvious discoveries, add a short note covering verified gotchas,
99
+ failed approaches, design rationale, or useful verification commands.
100
+ - Keep notes concise and evidence-based. Create `docs/` when the first note is
101
+ useful; do not leave an empty directory.
102
+ - Do not store secrets, cookies, HAR files, browser storage, or other transient
103
+ session data in `docs/`.
104
+
82
105
  ## Safety
83
106
 
84
107
  - Do not commit secrets, cookies, HAR files, browser storage, or