@foldspace_npm/harness 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -93,7 +93,7 @@ npx --yes @foldspace_npm/harness init
93
93
  Non-interactive / CI form:
94
94
 
95
95
  ```bash
96
- npx --yes @foldspace_npm/harness init my-agent \
96
+ npx --yes @foldspace_npm/harness init foldspace-agent \
97
97
  --product-id FR8JUQZAQRZB \
98
98
  --agent-key my-agent \
99
99
  --domain app.example.com \
@@ -103,24 +103,25 @@ npx --yes @foldspace_npm/harness init my-agent \
103
103
  When running directly from a harness checkout during development:
104
104
 
105
105
  ```bash
106
- node bin/cli.mjs init ../my-agent \
106
+ node bin/cli.mjs init ../foldspace-agent \
107
107
  --product-id FR8JUQZAQRZB \
108
108
  --agent-key my-agent \
109
109
  --domain app.example.com
110
110
  ```
111
111
 
112
- The Agent Key is the value shown in Agent Studio, such as `my-agent`.
113
- `--agent-api-name` remains a deprecated alias for `--agent-key`. `--name` is
114
- optional and defaults to the target directory name. The product ID must be the
115
- bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname or an
116
- HTTP(S) URL without a port or path.
112
+ The Agent Key is the value shown in Agent Studio, such as `my-agent`. It is
113
+ not the sidecar directory name. `--agent-api-name` remains a deprecated alias
114
+ for `--agent-key`. `--name` is optional and defaults to the target directory
115
+ name (`foldspace-agent` unless you pass a directory). The product ID must be
116
+ the bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname
117
+ or an HTTP(S) URL without a port or path.
117
118
 
118
119
  For safety, `init` requires a target path that does not exist. It does not
119
120
  initialize Git or overwrite files. On a TTY it can offer
120
121
  `npm install --ignore-scripts` after scaffolding. After creation:
121
122
 
122
123
  ```bash
123
- cd my-agent
124
+ cd foldspace-agent
124
125
  npm install --ignore-scripts # if you skipped the install prompt
125
126
  npm run build
126
127
  npm run inject
@@ -130,6 +131,25 @@ npm run attach
130
131
  The generated npm scripts intentionally remain the normal project interface;
131
132
  `foldspace init` is the one-time project creation command.
132
133
 
134
+ ### Lint handlers before they ship
135
+
136
+ `foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
137
+ print and still produce `dist/index.js`.
138
+
139
+ ```bash
140
+ foldspace lint
141
+ foldspace lint --json
142
+ ```
143
+
144
+ v1 flags:
145
+
146
+ - **error** `no-static-agent-prompt` — `execute` / chatterblock `callback` returning copilot instructions (`directive`, “tell the user”, …)
147
+ - **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
148
+ - **warning** `no-emoji` — emoji in handler or `agent/views` string literals
149
+ - **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
150
+
151
+ Put action instructions in Agent Studio or MCP. `foldspace lint` cannot see that copy.
152
+
133
153
  ### Choose an attach mode
134
154
 
135
155
  - **Swap (default):** the page already uses the configured product and agent.
package/bin/build.ts CHANGED
@@ -2,6 +2,7 @@ import * as esbuild from "esbuild";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { lintProject, printLint } from "../src/lint/index.mjs";
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
  const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
@@ -10,6 +11,14 @@ const agentActionsEntry = path.join(projectDir, "agent", "actions", "index.ts");
10
11
 
11
12
  const watchMode = process.argv.includes("--watch");
12
13
 
14
+ function gateLint(): void {
15
+ const result = lintProject(projectDir);
16
+ printLint(result);
17
+ if (result.errorCount > 0) {
18
+ process.exit(1);
19
+ }
20
+ }
21
+
13
22
  function buildOptions(): esbuild.BuildOptions {
14
23
  return {
15
24
  entryPoints: [agentActionsEntry],
@@ -27,6 +36,13 @@ function rebuildPlugin(): esbuild.Plugin {
27
36
  return {
28
37
  name: "rebuild-notify",
29
38
  setup(build) {
39
+ build.onStart(() => {
40
+ const result = lintProject(projectDir);
41
+ printLint(result);
42
+ if (result.errorCount > 0) {
43
+ throw new Error("foldspace lint failed");
44
+ }
45
+ });
30
46
  build.onEnd((result) => {
31
47
  const time = new Date().toLocaleTimeString();
32
48
  if (result.errors.length === 0) {
@@ -47,6 +63,10 @@ async function build() {
47
63
  process.exit(1);
48
64
  }
49
65
 
66
+ if (!watchMode) {
67
+ gateLint();
68
+ }
69
+
50
70
  if (!fs.existsSync(distDir)) {
51
71
  fs.mkdirSync(distDir, { recursive: true });
52
72
  }
package/bin/lint.mjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { lintProject, printLint } from "../src/lint/index.mjs";
4
+
5
+ const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
6
+ const json = process.argv.includes("--json");
7
+ const result = lintProject(projectDir);
8
+ printLint(result, { json });
9
+ process.exit(result.errorCount > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,8 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "esbuild": "^0.20.0",
21
- "tsx": "^4.7.0"
21
+ "tsx": "^4.7.0",
22
+ "typescript": "^5.3.3"
22
23
  },
23
24
  "engines": {
24
25
  "node": ">=20"
@@ -74,9 +74,33 @@ export const CLI_COMMANDS = Object.freeze([
74
74
  positionals: [],
75
75
  options: [flag("--watch", "Rebuild when action source changes")],
76
76
  prerequisites: ["agent/actions/index.ts"],
77
- effects: ["Writes dist/index.js"],
77
+ effects: [
78
+ "Runs foldspace lint first; errors skip bundling",
79
+ "Writes dist/index.js when lint has no errors",
80
+ ],
78
81
  next: ["Run foldspace inject", "Run foldspace attach"],
79
82
  }),
83
+ Object.freeze({
84
+ name: "lint",
85
+ entry: "lint.mjs",
86
+ group: "develop",
87
+ summary: "Check action handlers for prompt, registry, and copy issues",
88
+ usage: "foldspace lint [--json]",
89
+ risk: "local-write",
90
+ environment: "node",
91
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
92
+ capabilities: ["artifact.build"],
93
+ positionals: [],
94
+ options: [
95
+ flag("--json", "Print findings as machine-readable JSON"),
96
+ ],
97
+ prerequisites: ["agent/actions/index.ts"],
98
+ effects: [
99
+ "Reads agent/actions (and agent/views when present); writes nothing",
100
+ "Errors fail the process; warnings print and still exit 0",
101
+ ],
102
+ next: ["Fix reported findings", "Run foldspace build"],
103
+ }),
80
104
  Object.freeze({
81
105
  name: "inject",
82
106
  entry: "inject.mjs",
package/src/init.mjs CHANGED
@@ -16,7 +16,7 @@ const allowedFlags = new Set([
16
16
  "domain",
17
17
  ]);
18
18
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
19
- const defaultDirectory = "my-agent";
19
+ const defaultDirectory = "foldspace-agent";
20
20
 
21
21
  export const initUsage = commandByName("init").usage;
22
22
 
@@ -306,7 +306,9 @@ function createTemplateValues(config, harnessVersion) {
306
306
  PACKAGE_DESCRIPTION_JSON: JSON.stringify(`Foldspace browser actions for ${displayName}.`),
307
307
  PRODUCT_ID_JSON: JSON.stringify(productId),
308
308
  AGENT_API_NAME_JSON: JSON.stringify(agentApiName),
309
+ APP_DOMAIN: target.domain,
309
310
  APP_DOMAIN_JSON: JSON.stringify(target.domain),
311
+ START_URL: target.startUrl,
310
312
  START_URL_JSON: JSON.stringify(target.startUrl),
311
313
  HOSTS_JSON: JSON.stringify(hosts),
312
314
  HARNESS_VERSION: harnessVersion,
@@ -0,0 +1,237 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ts } from "./ts.mjs";
4
+
5
+ export function listSourceFiles(dir) {
6
+ if (!fs.existsSync(dir)) return [];
7
+ const out = [];
8
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9
+ const full = path.join(dir, entry.name);
10
+ if (entry.isDirectory()) {
11
+ out.push(...listSourceFiles(full));
12
+ continue;
13
+ }
14
+ if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
15
+ out.push(full);
16
+ }
17
+ }
18
+ return out.sort();
19
+ }
20
+
21
+ export function shouldSkipActionFile(filePath) {
22
+ const base = path.basename(filePath);
23
+ return base.startsWith("_example") || /\.test\./.test(base);
24
+ }
25
+
26
+ export function parseFile(filePath) {
27
+ const text = fs.readFileSync(filePath, "utf8");
28
+ const kind = filePath.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS;
29
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, kind);
30
+ }
31
+
32
+ export function loc(sourceFile, node) {
33
+ const start = node.getStart(sourceFile);
34
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
35
+ return { line: line + 1, column: character + 1 };
36
+ }
37
+
38
+ export function propertyName(node) {
39
+ if (!node?.name) return null;
40
+ if (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) {
41
+ return node.name.text;
42
+ }
43
+ if (ts.isStringLiteral(node.name) || ts.isNumericLiteral(node.name)) {
44
+ return node.name.text;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function normalizeKey(name) {
50
+ return String(name || "")
51
+ .replace(/_/g, "")
52
+ .toLowerCase();
53
+ }
54
+
55
+ export function isFunctionLike(node) {
56
+ return (
57
+ node &&
58
+ (ts.isFunctionDeclaration(node) ||
59
+ ts.isFunctionExpression(node) ||
60
+ ts.isArrowFunction(node) ||
61
+ ts.isMethodDeclaration(node) ||
62
+ ts.isConstructorDeclaration(node))
63
+ );
64
+ }
65
+
66
+ function initializerOf(node) {
67
+ if (ts.isPropertyAssignment(node)) return node.initializer;
68
+ if (ts.isMethodDeclaration(node)) return node;
69
+ if (ts.isPropertyDeclaration(node)) return node.initializer;
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Call `visit(fn, name)` for execute / render / callback function-likes.
75
+ */
76
+ export function forEachNamedFunction(sourceFile, names, visit) {
77
+ const wanted = names instanceof Set ? names : new Set(names);
78
+
79
+ function walk(node) {
80
+ if (
81
+ ts.isPropertyAssignment(node) ||
82
+ ts.isMethodDeclaration(node) ||
83
+ ts.isPropertyDeclaration(node)
84
+ ) {
85
+ const name = propertyName(node);
86
+ if (name && wanted.has(name)) {
87
+ const target = initializerOf(node);
88
+ if (isFunctionLike(target)) visit(target, name, node);
89
+ }
90
+ }
91
+ ts.forEachChild(node, walk);
92
+ }
93
+
94
+ walk(sourceFile);
95
+ }
96
+
97
+ export function callbackParamName(fn) {
98
+ const params = fn.parameters || [];
99
+ if (params.length >= 4 && ts.isIdentifier(params[3].name)) {
100
+ return params[3].name.text;
101
+ }
102
+ return "callback";
103
+ }
104
+
105
+ export function collectConstBindings(scopeNode) {
106
+ const map = new Map();
107
+
108
+ function visit(node) {
109
+ if (node !== scopeNode && isFunctionLike(node)) return;
110
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
111
+ map.set(node.name.text, node.initializer);
112
+ }
113
+ ts.forEachChild(node, visit);
114
+ }
115
+
116
+ visit(scopeNode);
117
+ return map;
118
+ }
119
+
120
+ export function collectModuleBindings(sourceFile) {
121
+ const map = new Map();
122
+ for (const stmt of sourceFile.statements) {
123
+ if (!ts.isVariableStatement(stmt)) continue;
124
+ for (const decl of stmt.declarationList.declarations) {
125
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
126
+ map.set(decl.name.text, decl.initializer);
127
+ }
128
+ }
129
+ }
130
+ return map;
131
+ }
132
+
133
+ export function resolveNode(node, bindings, depth = 0) {
134
+ if (!node || depth > 8) return node;
135
+ if (ts.isIdentifier(node)) {
136
+ const next = bindings.get(node.text);
137
+ if (!next) return node;
138
+ return resolveNode(next, bindings, depth + 1);
139
+ }
140
+ if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) {
141
+ return resolveNode(node.expression, bindings, depth + 1);
142
+ }
143
+ return node;
144
+ }
145
+
146
+ export function stringText(node) {
147
+ if (!node) return null;
148
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
149
+ return node.text;
150
+ }
151
+ if (ts.isTemplateExpression(node)) {
152
+ return [
153
+ node.head.text,
154
+ ...node.templateSpans.map((span) => span.literal.text),
155
+ ].join("");
156
+ }
157
+ return null;
158
+ }
159
+
160
+ export function forEachStringLiteral(node, visit, { skipNestedFunctions = false, root = node } = {}) {
161
+ function walk(current) {
162
+ if (skipNestedFunctions && current !== root && isFunctionLike(current)) return;
163
+ if (
164
+ ts.isStringLiteral(current) ||
165
+ ts.isNoSubstitutionTemplateLiteral(current) ||
166
+ ts.isTemplateExpression(current)
167
+ ) {
168
+ visit(current);
169
+ }
170
+ ts.forEachChild(current, walk);
171
+ }
172
+ walk(node);
173
+ }
174
+
175
+ export function forEachReturn(fn, visit) {
176
+ if (ts.isArrowFunction(fn) && fn.body && !ts.isBlock(fn.body)) {
177
+ visit(fn.body, fn);
178
+ return;
179
+ }
180
+ function walk(node) {
181
+ if (node !== fn && isFunctionLike(node)) return;
182
+ if (ts.isReturnStatement(node) && node.expression) visit(node.expression, node);
183
+ ts.forEachChild(node, walk);
184
+ }
185
+ walk(fn);
186
+ }
187
+
188
+ export function forEachCallNamed(fn, name, visit) {
189
+ function walk(node) {
190
+ if (node !== fn && isFunctionLike(node)) return;
191
+ if (ts.isCallExpression(node)) {
192
+ if (ts.isIdentifier(node.expression) && node.expression.text === name) {
193
+ visit(node);
194
+ }
195
+ }
196
+ ts.forEachChild(node, walk);
197
+ }
198
+ walk(fn);
199
+ }
200
+
201
+ export function objectProperties(obj) {
202
+ if (!obj || !ts.isObjectLiteralExpression(obj)) return [];
203
+ return obj.properties.filter(
204
+ (prop) => ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop),
205
+ );
206
+ }
207
+
208
+ export function propertyValue(prop) {
209
+ if (ts.isShorthandPropertyAssignment(prop)) return prop.name;
210
+ return prop.initializer;
211
+ }
212
+
213
+ export function resolveModulePath(fromFile, specifier) {
214
+ if (!specifier || !specifier.startsWith(".")) return null;
215
+ const base = path.resolve(path.dirname(fromFile), specifier);
216
+ const candidates = [
217
+ base,
218
+ `${base}.ts`,
219
+ `${base}.js`,
220
+ path.join(base, "index.ts"),
221
+ path.join(base, "index.js"),
222
+ ];
223
+ return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || null;
224
+ }
225
+
226
+ export function finding({ rule, severity, sourceFile, node, message, fix, file }) {
227
+ const position = loc(sourceFile, node);
228
+ return {
229
+ rule,
230
+ severity,
231
+ file,
232
+ line: position.line,
233
+ column: position.column,
234
+ message,
235
+ fix,
236
+ };
237
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
4
+ import { lintActionFile, lintViewsFile, ruleRegistryIntegrity } from "./rules.mjs";
5
+
6
+ export const LINT_SCHEMA_VERSION = 1;
7
+
8
+ export function lintProject(projectDir) {
9
+ const actionsDir = path.join(projectDir, "agent", "actions");
10
+ const indexFile = path.join(actionsDir, "index.ts");
11
+ const indexJs = path.join(actionsDir, "index.js");
12
+ const entry = fs.existsSync(indexFile) ? indexFile : fs.existsSync(indexJs) ? indexJs : null;
13
+
14
+ if (!entry) {
15
+ return {
16
+ ok: false,
17
+ errorCount: 1,
18
+ warningCount: 0,
19
+ findings: [
20
+ {
21
+ rule: "registry-integrity",
22
+ severity: "error",
23
+ file: "agent/actions/index.ts",
24
+ line: 1,
25
+ column: 1,
26
+ message: "Entry point not found: agent/actions/index.ts",
27
+ fix: "Create agent/actions/index.ts and register handlers there.",
28
+ },
29
+ ],
30
+ };
31
+ }
32
+
33
+ const findings = [];
34
+ const actionFiles = listSourceFiles(actionsDir);
35
+
36
+ for (const filePath of actionFiles) {
37
+ lintActionFile(filePath, path.relative(projectDir, filePath), findings);
38
+ }
39
+
40
+ const viewsDir = path.join(projectDir, "agent", "views");
41
+ if (fs.existsSync(viewsDir)) {
42
+ for (const filePath of listSourceFiles(viewsDir)) {
43
+ lintViewsFile(filePath, path.relative(projectDir, filePath), findings);
44
+ }
45
+ }
46
+
47
+ ruleRegistryIntegrity({
48
+ indexFile: entry,
49
+ indexSource: parseFile(entry),
50
+ actionFiles,
51
+ projectDir,
52
+ findings,
53
+ });
54
+
55
+ const errorCount = findings.filter((item) => item.severity === "error").length;
56
+ const warningCount = findings.filter((item) => item.severity === "warning").length;
57
+ return {
58
+ ok: errorCount === 0,
59
+ errorCount,
60
+ warningCount,
61
+ findings,
62
+ };
63
+ }
64
+
65
+ function formatFinding(item) {
66
+ return `${item.file}:${item.line}:${item.column} ${item.severity} ${item.rule} ${item.message}\n Fix: ${item.fix}`;
67
+ }
68
+
69
+ export function renderLintText(result) {
70
+ if (!result.findings.length) {
71
+ return "foldspace lint: no issues";
72
+ }
73
+ const body = result.findings.map(formatFinding).join("\n");
74
+ const summary = `${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`;
75
+ return `${body}\n\n${summary}`;
76
+ }
77
+
78
+ export function renderLintJson(result) {
79
+ return {
80
+ kind: "foldspace.lint",
81
+ schemaVersion: LINT_SCHEMA_VERSION,
82
+ ok: result.ok,
83
+ errorCount: result.errorCount,
84
+ warningCount: result.warningCount,
85
+ findings: result.findings,
86
+ };
87
+ }
88
+
89
+ export function printLint(result, { json = false, stdout = console.log, stderr = console.error } = {}) {
90
+ if (json) {
91
+ stdout(JSON.stringify(renderLintJson(result), null, 2));
92
+ return;
93
+ }
94
+ const text = renderLintText(result);
95
+ if (result.errorCount > 0) stderr(text);
96
+ else stdout(text);
97
+ }
98
+
99
+ export function shouldSkipFile(filePath) {
100
+ return shouldSkipActionFile(filePath);
101
+ }
@@ -0,0 +1,464 @@
1
+ import path from "node:path";
2
+ import {
3
+ callbackParamName,
4
+ collectConstBindings,
5
+ collectModuleBindings,
6
+ finding,
7
+ forEachCallNamed,
8
+ forEachNamedFunction,
9
+ forEachReturn,
10
+ forEachStringLiteral,
11
+ isFunctionLike,
12
+ normalizeKey,
13
+ objectProperties,
14
+ parseFile,
15
+ propertyName,
16
+ propertyValue,
17
+ resolveModulePath,
18
+ resolveNode,
19
+ shouldSkipActionFile,
20
+ stringText,
21
+ } from "./ast.mjs";
22
+ import { ts } from "./ts.mjs";
23
+
24
+ /*
25
+ Backlog, not this slice:
26
+ - no-hardcoded-secrets
27
+ - no-unsafe-return (stack / cookies / Authorization / ApiResult.detail)
28
+ - snake_case action keys
29
+ - credentials: "include" on every fetch
30
+ - _gen2 vs _v2 taskKey
31
+ */
32
+
33
+ const BANNED_PROMPT_KEYS = new Set([
34
+ "directive",
35
+ "instructions",
36
+ "guidance",
37
+ "systemprompt",
38
+ "agentinstructions",
39
+ "nextprompt",
40
+ ]);
41
+
42
+ const RUNTASK_PROMPT_KEYS = new Set(["prompt", "instructions", "systemprompt"]);
43
+
44
+ const IMPERATIVE_PATTERNS = [
45
+ /\btell the user\b/i,
46
+ /\bask the user\b/i,
47
+ /\bask if they(?:'d| would)\b/i,
48
+ /\bdo not (?:repeat|call|summarize)\b/i,
49
+ /\bdon't (?:repeat|call|summarize)\b/i,
50
+ /\bsummarize the following\b/i,
51
+ /\brun the [\w-]+ action\b/i,
52
+ /\bwalk the user through\b/i,
53
+ /\bproceed to the next step\b/i,
54
+ /\blet the user know\b/i,
55
+ ];
56
+
57
+ const EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
58
+
59
+ const PROMPT_FIX =
60
+ "Put the wording in the action or agent instructions via MCP or Agent Studio; return data only.";
61
+
62
+ function mergeBindings(fn, sourceFile) {
63
+ return new Map([
64
+ ...collectModuleBindings(sourceFile),
65
+ ...collectConstBindings(fn),
66
+ ]);
67
+ }
68
+
69
+ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, origin) {
70
+ if (!obj || !ts.isObjectLiteralExpression(obj)) return;
71
+
72
+ for (const prop of objectProperties(obj)) {
73
+ const name = propertyName(prop);
74
+ const normalized = normalizeKey(name);
75
+ const valueNode = resolveNode(propertyValue(prop), bindings);
76
+ const at = prop.name || prop;
77
+
78
+ if (BANNED_PROMPT_KEYS.has(normalized)) {
79
+ findings.push(
80
+ finding({
81
+ rule: "no-static-agent-prompt",
82
+ severity: "error",
83
+ sourceFile,
84
+ node: at,
85
+ file: relative,
86
+ message: `Do not return '${name}' — that is a second instruction channel besides Agent Studio.`,
87
+ fix: PROMPT_FIX,
88
+ }),
89
+ );
90
+ }
91
+
92
+ const text = stringText(valueNode);
93
+ if (text && IMPERATIVE_PATTERNS.some((pattern) => pattern.test(text))) {
94
+ findings.push(
95
+ finding({
96
+ rule: "no-static-agent-prompt",
97
+ severity: "error",
98
+ sourceFile,
99
+ node: at,
100
+ file: relative,
101
+ message: `${origin} sends the copilot operating instructions ('${text.slice(0, 80)}').`,
102
+ fix: PROMPT_FIX,
103
+ }),
104
+ );
105
+ }
106
+
107
+ if (valueNode && ts.isObjectLiteralExpression(valueNode)) {
108
+ inspectReturnedObject(valueNode, bindings, sourceFile, relative, findings, origin);
109
+ }
110
+ }
111
+ }
112
+
113
+ export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
114
+ forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn, name) => {
115
+ const bindings = mergeBindings(fn, sourceFile);
116
+
117
+ if (name === "execute" || name === "callback") {
118
+ forEachReturn(fn, (expression) => {
119
+ inspectReturnedObject(
120
+ resolveNode(expression, bindings),
121
+ bindings,
122
+ sourceFile,
123
+ relative,
124
+ findings,
125
+ name,
126
+ );
127
+ });
128
+ }
129
+
130
+ if (name === "render") {
131
+ const cbName = callbackParamName(fn);
132
+ forEachCallNamed(fn, cbName, (call) => {
133
+ const arg = call.arguments[0];
134
+ if (!arg) return;
135
+ inspectReturnedObject(
136
+ resolveNode(arg, bindings),
137
+ bindings,
138
+ sourceFile,
139
+ relative,
140
+ findings,
141
+ "callback",
142
+ );
143
+ });
144
+ }
145
+ });
146
+ }
147
+
148
+ function inspectRunTaskData(dataNode, bindings, sourceFile, relative, findings, call) {
149
+ const resolved = resolveNode(dataNode, bindings);
150
+ if (!resolved || !ts.isObjectLiteralExpression(resolved)) return;
151
+
152
+ for (const prop of objectProperties(resolved)) {
153
+ const name = propertyName(prop);
154
+ if (!RUNTASK_PROMPT_KEYS.has(normalizeKey(name))) continue;
155
+ const value = resolveNode(propertyValue(prop), bindings);
156
+ if (stringText(value) === null) continue;
157
+ findings.push(
158
+ finding({
159
+ rule: "no-runtask-prompt",
160
+ severity: "error",
161
+ sourceFile,
162
+ node: prop.name || call,
163
+ file: relative,
164
+ message: `runTask data.${name} is a prompt. Task-agent instructions belong in Agent Studio.`,
165
+ fix: "Keep task-agent instructions in Agent Studio; pass only extracted facts in data.",
166
+ }),
167
+ );
168
+ }
169
+ }
170
+
171
+ export function ruleNoRunTaskPrompt(sourceFile, relative, findings) {
172
+ const moduleBindings = collectModuleBindings(sourceFile);
173
+
174
+ function walk(node, fnBindings) {
175
+ if (isFunctionLike(node) && node !== sourceFile) {
176
+ const nested = mergeBindings(node, sourceFile);
177
+ ts.forEachChild(node, (child) => walk(child, nested));
178
+ return;
179
+ }
180
+
181
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
182
+ if (node.expression.name.text === "runTask") {
183
+ const arg = node.arguments[0];
184
+ const bindings = fnBindings || moduleBindings;
185
+ const config = resolveNode(arg, bindings);
186
+ if (config && ts.isObjectLiteralExpression(config)) {
187
+ for (const prop of objectProperties(config)) {
188
+ if (propertyName(prop) !== "data") continue;
189
+ inspectRunTaskData(propertyValue(prop), bindings, sourceFile, relative, findings, node);
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ ts.forEachChild(node, (child) => walk(child, fnBindings));
196
+ }
197
+
198
+ walk(sourceFile, moduleBindings);
199
+ }
200
+
201
+ function reportEmoji(node, sourceFile, relative, findings) {
202
+ const text = stringText(node);
203
+ if (!text || !EMOJI_PATTERN.test(text)) return;
204
+ findings.push(
205
+ finding({
206
+ rule: "no-emoji",
207
+ severity: "warning",
208
+ sourceFile,
209
+ node,
210
+ file: relative,
211
+ message: "Emoji in handler or widget copy reads as off-brand in a product UI that has none.",
212
+ fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the MCP cannot write that field, so each new action's Studio instructions must carry the line too.",
213
+ }),
214
+ );
215
+ }
216
+
217
+ export function ruleNoEmoji(sourceFile, relative, findings, { wholeFile = false } = {}) {
218
+ if (wholeFile) {
219
+ forEachStringLiteral(sourceFile, (node) => {
220
+ if (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent)) return;
221
+ reportEmoji(node, sourceFile, relative, findings);
222
+ });
223
+ return;
224
+ }
225
+
226
+ forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn) => {
227
+ const bindings = mergeBindings(fn, sourceFile);
228
+ forEachStringLiteral(
229
+ fn,
230
+ (node) => reportEmoji(node, sourceFile, relative, findings),
231
+ { skipNestedFunctions: true, root: fn },
232
+ );
233
+
234
+ function walk(current) {
235
+ if (current !== fn && isFunctionLike(current)) return;
236
+ if (ts.isIdentifier(current)) {
237
+ const resolved = resolveNode(current, bindings);
238
+ if (
239
+ resolved &&
240
+ resolved !== current &&
241
+ (resolved.getStart(sourceFile) < fn.getStart(sourceFile) ||
242
+ resolved.getEnd() > fn.getEnd())
243
+ ) {
244
+ reportEmoji(resolved, sourceFile, relative, findings);
245
+ }
246
+ }
247
+ ts.forEachChild(current, walk);
248
+ }
249
+ walk(fn);
250
+ });
251
+ }
252
+
253
+ function exportedBindings(sourceFile) {
254
+ const exports = [];
255
+
256
+ function objectHasExecute(node) {
257
+ if (!node || !ts.isObjectLiteralExpression(node)) return false;
258
+ return objectProperties(node).some((prop) => propertyName(prop) === "execute");
259
+ }
260
+
261
+ for (const stmt of sourceFile.statements) {
262
+ if (ts.isVariableStatement(stmt) && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
263
+ for (const decl of stmt.declarationList.declarations) {
264
+ if (!ts.isIdentifier(decl.name)) continue;
265
+ const init = decl.initializer;
266
+ exports.push({
267
+ name: decl.name.text,
268
+ node: decl.name,
269
+ hasExecute: objectHasExecute(init),
270
+ isFunction: isFunctionLike(init),
271
+ });
272
+ }
273
+ }
274
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
275
+ exports.push({
276
+ name: stmt.name.text,
277
+ node: stmt.name,
278
+ hasExecute: false,
279
+ isFunction: true,
280
+ });
281
+ }
282
+ }
283
+ return exports;
284
+ }
285
+
286
+ function collectImports(sourceFile, filePath) {
287
+ const map = new Map();
288
+ for (const stmt of sourceFile.statements) {
289
+ if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue;
290
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
291
+ const resolved = resolveModulePath(filePath, stmt.moduleSpecifier.text);
292
+ if (!resolved) continue;
293
+ const clause = stmt.importClause;
294
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
295
+ for (const spec of clause.namedBindings.elements) {
296
+ const imported = (spec.propertyName || spec.name).text;
297
+ map.set(spec.name.text, { file: resolved, imported });
298
+ }
299
+ }
300
+ if (clause.name) {
301
+ map.set(clause.name.text, { file: resolved, imported: "default" });
302
+ }
303
+ }
304
+ return map;
305
+ }
306
+
307
+ function findRegistryObject(sourceFile) {
308
+ let found = null;
309
+
310
+ function consider(node) {
311
+ if (node && ts.isObjectLiteralExpression(node)) found = found || node;
312
+ }
313
+
314
+ function walk(node) {
315
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === "actions") {
316
+ consider(node.initializer);
317
+ }
318
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
319
+ const left = node.left.getText(sourceFile);
320
+ if (left.includes("__FOLDSPACE_REMOTE_ACTIONS__") || left.endsWith(".actions")) {
321
+ if (ts.isIdentifier(node.right) && node.right.text === "actions") {
322
+ // resolved via variable declaration
323
+ } else {
324
+ consider(node.right);
325
+ }
326
+ }
327
+ }
328
+ ts.forEachChild(node, walk);
329
+ }
330
+
331
+ walk(sourceFile);
332
+ return found;
333
+ }
334
+
335
+ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, projectDir, findings }) {
336
+ const relativeIndex = path.relative(projectDir, indexFile);
337
+ const registry = findRegistryObject(indexSource);
338
+ const imports = collectImports(indexSource, indexFile);
339
+ const seenKeys = new Map();
340
+ const registeredNames = new Set();
341
+
342
+ if (registry) {
343
+ for (const prop of objectProperties(registry)) {
344
+ const key = propertyName(prop);
345
+ if (!key) continue;
346
+ if (seenKeys.has(key)) {
347
+ findings.push(
348
+ finding({
349
+ rule: "registry-integrity",
350
+ severity: "warning",
351
+ sourceFile: indexSource,
352
+ node: prop.name || prop,
353
+ file: relativeIndex,
354
+ message: `Duplicate registry key '${key}'. The second entry silently wins.`,
355
+ fix: "Give each registered action a unique key that matches Agent Studio.",
356
+ }),
357
+ );
358
+ }
359
+ seenKeys.set(key, prop);
360
+
361
+ const value = propertyValue(prop);
362
+ if (isFunctionLike(value)) {
363
+ findings.push(
364
+ finding({
365
+ rule: "registry-integrity",
366
+ severity: "warning",
367
+ sourceFile: indexSource,
368
+ node: prop.name || prop,
369
+ file: relativeIndex,
370
+ message: `Registry entry '${key}' is a function. The SDK expects { execute }.`,
371
+ fix: "Register an object with an execute function.",
372
+ }),
373
+ );
374
+ continue;
375
+ }
376
+ if (ts.isObjectLiteralExpression(value)) {
377
+ const hasExecute = objectProperties(value).some((entry) => propertyName(entry) === "execute");
378
+ if (!hasExecute) {
379
+ findings.push(
380
+ finding({
381
+ rule: "registry-integrity",
382
+ severity: "warning",
383
+ sourceFile: indexSource,
384
+ node: prop.name || prop,
385
+ file: relativeIndex,
386
+ message: `Registry entry '${key}' is an object without execute.`,
387
+ fix: "Register an object with an execute function.",
388
+ }),
389
+ );
390
+ }
391
+ continue;
392
+ }
393
+ if (ts.isIdentifier(value)) {
394
+ registeredNames.add(value.text);
395
+ const imported = imports.get(value.text);
396
+ if (imported) {
397
+ const exported = exportedBindings(parseFile(imported.file)).find(
398
+ (item) => item.name === imported.imported || item.name === value.text,
399
+ );
400
+ if (exported?.isFunction && !exported.hasExecute) {
401
+ findings.push(
402
+ finding({
403
+ rule: "registry-integrity",
404
+ severity: "warning",
405
+ sourceFile: indexSource,
406
+ node: prop.name || prop,
407
+ file: relativeIndex,
408
+ message: `Registry entry '${key}' points at a bare function, not { execute }.`,
409
+ fix: "Export and register an object with an execute function.",
410
+ }),
411
+ );
412
+ } else if (exported && !exported.hasExecute && !exported.isFunction) {
413
+ findings.push(
414
+ finding({
415
+ rule: "registry-integrity",
416
+ severity: "warning",
417
+ sourceFile: indexSource,
418
+ node: prop.name || prop,
419
+ file: relativeIndex,
420
+ message: `Registry entry '${key}' points at an object without execute.`,
421
+ fix: "Export and register an object with an execute function.",
422
+ }),
423
+ );
424
+ }
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ for (const filePath of actionFiles) {
431
+ if (filePath === indexFile) continue;
432
+ if (shouldSkipActionFile(filePath)) continue;
433
+ const sourceFile = parseFile(filePath);
434
+ const relative = path.relative(projectDir, filePath);
435
+ for (const exported of exportedBindings(sourceFile)) {
436
+ if (!exported.hasExecute) continue;
437
+ if (registeredNames.has(exported.name) || seenKeys.has(exported.name)) continue;
438
+ findings.push(
439
+ finding({
440
+ rule: "registry-integrity",
441
+ severity: "warning",
442
+ sourceFile,
443
+ node: exported.node,
444
+ file: relative,
445
+ message: `'${exported.name}' exports { execute } but is not registered in agent/actions/index.ts.`,
446
+ fix: "Import it and add it to the actions object. An unregistered handler never fires.",
447
+ }),
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ export function lintActionFile(filePath, relative, findings) {
454
+ if (shouldSkipActionFile(filePath)) return;
455
+ const sourceFile = parseFile(filePath);
456
+ ruleNoStaticAgentPrompt(sourceFile, relative, findings);
457
+ ruleNoRunTaskPrompt(sourceFile, relative, findings);
458
+ ruleNoEmoji(sourceFile, relative, findings);
459
+ }
460
+
461
+ export function lintViewsFile(filePath, relative, findings) {
462
+ const sourceFile = parseFile(filePath);
463
+ ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
464
+ }
@@ -0,0 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const require = createRequire(import.meta.url);
4
+
5
+ /** TypeScript compiler API, resolved from this package — not the tenant. */
6
+ export const ts = require("typescript");
@@ -29,11 +29,13 @@ from actions that read or mutate a selected resource.
29
29
  3. Capture at least one real HTTP 200 for the data the action needs. Use
30
30
  chrome-devtools MCP against the inject Chrome, or a page-context `fetch`,
31
31
  **before** `attach` owns the debug port. If there is no 200,
32
- pivot; do not create Foldspace resources.
32
+ pivot; do not create Foldspace resources. Write what you establish into
33
+ `docs/app-profile.md` with **how you know it**. Do not promote an assumption
34
+ by quoting that file.
33
35
  4. Create action metadata as a draft. `generate_action_handler` works from the
34
36
  draft schema; do not publish yet.
35
37
  5. Implement the handler using the observed request and response shapes.
36
- 6. Register the handler in `agent/actions/index.ts` and build.
38
+ 6. Register the handler in `agent/actions/index.ts` and build (`foldspace build` lints first).
37
39
  7. Run `npx foldspace attach --daemon` (add `--bootstrap` or `--replace` when
38
40
  the page requires it). An empty local registry is valid if you only want to
39
41
  see how the agent works.
@@ -52,7 +54,36 @@ Actions execute in the user's signed-in browser session.
52
54
  - Verify that the user is signed in before observing a workflow.
53
55
  - Do not substitute a public developer API when the browser session is missing;
54
56
  ask the user to sign in.
55
- - Validate parameters and return sanitized errors.
57
+ - Validate parameters and return sanitized errors. Return **data only** — never
58
+ `directive`, `instructions`, or a paragraph telling the copilot what to say.
59
+ Action and agent instructions live in Agent Studio / MCP.
60
+ - Actions that return data the user will inspect should include a `render`
61
+ function for in-chat UI (a chatterblock). If it makes more sense to output the data
62
+ in a UI component instead of text then consider using render to show a component.
63
+ - `render` receives **`execute`'s return value**, not the action's input params.
64
+ Returning anything that lacks the ids the card needs is why widgets pass in
65
+ isolation and fail in the real chat.
66
+ - `runAction` refuses render actions (`cannot be executed silently`). Driving
67
+ `render()` yourself never runs `execute()`, so it does not test that contract.
68
+ - Check https://docs.foldspace.ai/guides/in-chat-ui/ for more information
69
+
70
+ ## Task agents
71
+
72
+ Use a Task Agent when the handler needs a one-time LLM subtask that
73
+ deterministic code cannot do well: extraction, summarization,
74
+ classification, normalization, enrichment, or generation.
75
+
76
+ Do not use a Task Agent for API calls, CRUD, routing, or parsing a
77
+ known response shape. Those stay in `execute`.
78
+
79
+ Task agents are created in Agent Studio, not in this repo. Ask before
80
+ creating or publishing one. Call a published task agent from the
81
+ handler with `runTask({ taskKey, data })` (if not published the runTask won't work).
82
+ `data` carries extracted facts only — not `prompt` / `instructions` strings.
83
+ Prefer JSON output when the handler must consume the result.
84
+
85
+ See https://docs.foldspace.ai/user-guides/task-agents/ and
86
+ https://docs.foldspace.ai/reference/task-agent-api/
56
87
 
57
88
  ## Local harness loop
58
89
 
@@ -93,41 +124,58 @@ execute/render lines; do not invoke the handler directly. Those lines record
93
124
  names, statuses, durations, and parameter keys only — not results or error
94
125
  bodies.
95
126
 
127
+ ## Product defaults
128
+
129
+ These are not Joist-specific. Lint can catch some of them in handler code;
130
+ Agent Studio copy is on you.
131
+
132
+ - **No emoji** in copilot replies, cards, or handler strings. Put that in the
133
+ agent's Behavior instructions (MCP cannot write that field, so each new
134
+ action's Studio `instructions` must carry the line too).
135
+ - **Never send the user out of the host app.** No "open in &lt;product&gt;"
136
+ button and no pasted URLs — navigate with a navigation route, same tab.
137
+ - **Check row counts before choosing the experience.** On an empty account the
138
+ useful first action is one that creates data.
139
+
96
140
  ## Verification gates
97
141
 
98
142
  Do not report success without all six:
99
143
 
100
- 1. TypeScript compiles with `npx tsc --noEmit -p tsconfig.json`.
101
- 2. The expected handler appears in `dist/index.js`.
102
- 3. The browser reports `inspect_registration:registration_ok`. For a named
144
+ 1. TypeScript compiles with `npm run typecheck` (`tsc --noEmit -p tsconfig.json`).
145
+ Do not run `npx tsc` that can install the wrong package.
146
+ 2. `foldspace lint` reports no errors (`foldspace build` runs this first).
147
+ 3. The expected handler appears in `dist/index.js`.
148
+ 4. The browser reports `inspect_registration:registration_ok`. For a named
103
149
  action, the captured registry includes that handler.
104
- 4. The action behaves correctly against the real target workflow. Confirm
150
+ 5. The action behaves correctly against the real target workflow. Confirm
105
151
  `[actions] local-handler:execute` in the attach log after the user exercises
106
- the visible agent.
107
- 5. Existing neighbouring action fixtures still pass when fixtures exist.
152
+ the visible agent. For a widget, that log must include `local-handler:render`
153
+ from a real chat turn not a hand-built `render()` call.
108
154
  6. Browser evidence came from the live target, not from hand-authored examples.
155
+ Neighbour fixtures still pass when they exist.
109
156
 
110
157
  ## Layout
111
158
 
112
- - `agent/actions/` — one handler per action, registered in `index.ts`
159
+ - `agent/actions/` — one handler per action (`execute`, optional `render`),
160
+ registered in `index.ts`
113
161
  - `agent/api/` — one HTTP helper per endpoint
114
162
  - `agent/constants.ts` — agent, product, and domain identifiers
115
163
  - `agent/utils.ts` — Foldspace agent lookup
116
164
  - `foldspace.dev.json` — local harness target configuration
117
- - `docs/`optional coding-agent learnings; create on demand
165
+ - `docs/app-profile.md`what is known about this app, and how it was established
118
166
 
119
167
  Do not introduce another bundler or bundle format.
120
168
 
121
169
  ## Agent learnings
122
170
 
123
- Use `docs/` to record durable, repository-specific lessons so later sessions do
124
- not repeat the same mistakes.
171
+ `docs/app-profile.md` is the durable record of this app. Fill it as you probe,
172
+ not afterwards. Other notes in `docs/` are fine for session-specific lessons.
125
173
 
126
- - Before similar work, read any relevant notes already in `docs/`.
127
- - After non-obvious discoveries, add a short note covering verified gotchas,
128
- failed approaches, design rationale, or useful verification commands.
129
- - Keep notes concise and evidence-based. Create `docs/` when the first note is
130
- useful; do not leave an empty directory.
174
+ - Before similar work, read `docs/app-profile.md` and any other notes in `docs/`.
175
+ - After non-obvious discoveries, add them to the profile (or a short extra
176
+ note) covering verified gotchas, failed approaches, and how they were
177
+ established.
178
+ - Keep notes concise and evidence-based.
131
179
  - Do not store secrets, cookies, HAR files, browser storage, or other transient
132
180
  session data in `docs/`.
133
181
 
@@ -15,4 +15,19 @@ export const example_action = {
15
15
  return { ok: false, error: detail };
16
16
  }
17
17
  },
18
+
19
+ // Optional chatterblock: uncomment to render in-chat UI instead of
20
+ // returning text-only data. See https://docs.foldspace.ai/guides/in-chat-ui/ for more information. Set
21
+ // awaitUserInput: true for forms and confirmations.
22
+ //
23
+ // render: (data, host, header, callback, cancel) => {
24
+ // host.replaceChildren();
25
+ // if (data?.ok === false) {
26
+ // host.textContent = data.error;
27
+ // return;
28
+ // }
29
+ // const card = document.createElement("div");
30
+ // card.textContent = data.echo;
31
+ // host.append(card);
32
+ // },
18
33
  };
@@ -0,0 +1,109 @@
1
+ # {{DISPLAY_NAME}} — app profile
2
+
3
+ Durable facts about `{{APP_DOMAIN}}` and its API, established by observing the
4
+ running app on **<date>** from a logged-in session.
5
+
6
+ **Everything here should be observed.** Each entry says how. Where something is
7
+ only believed, say so in the same breath — do not promote an assumption by
8
+ quoting this file. Re-run the probe before trusting an entry that matters.
9
+
10
+ Do not store secrets, cookies, HAR files, tokens, or session data here. Record
11
+ *where* the token lives and *what a redacted value looks like*, never the value.
12
+
13
+ How to re-run any probe below:
14
+
15
+ ```bash
16
+ npm run inject
17
+ # Sign in. Capture from chrome-devtools MCP, or a page-context fetch,
18
+ # before foldspace attach owns the debug port.
19
+ ```
20
+
21
+ Start URL: `{{START_URL}}`
22
+
23
+ ---
24
+
25
+ ## Auth
26
+
27
+ - Where the token lives (cookie name, `localStorage` key, custom header), and
28
+ how you know — wrapping XHR/`fetch` on the live page beats guessing.
29
+ - Its lifetime, measured. (One app's expired in ~52 minutes and killed three
30
+ probing sessions.)
31
+ - What an expired or refused response looks like, verbatim. A CORS-less refusal
32
+ often surfaces as `TypeError: Failed to fetch` with no status — that is not
33
+ "host down".
34
+ - Anything inherited but never exercised — mark it **assumed**.
35
+
36
+ ## API base
37
+
38
+ - The exact base, including any explicit port. Note if omitting `:443` fails
39
+ CORS, and whether the API is a **different host** from the app (a relative
40
+ path then returns the HTML shell).
41
+
42
+ ## Account contents
43
+
44
+ Row counts from the signed-in account **before** choosing the experience. On an
45
+ empty account the only useful first action is one that creates data.
46
+
47
+ | Resource | Count | How established |
48
+ |---|---|---|
49
+ | | | |
50
+
51
+ ## Endpoints called and observed
52
+
53
+ For each: the path, the **envelope**, the row keys you actually read, and how
54
+ you established them.
55
+
56
+ > Watch for endpoints on the same host that wrap the same resource
57
+ > differently (`{data}` vs `{contacts}` vs `{items}`). Reaching for the wrong
58
+ > key yields `undefined`, not an empty array — it throws downstream rather
59
+ > than returning nothing.
60
+
61
+ | Path | Method | Envelope | Verified |
62
+ |---|---|---|---|
63
+ | | | | |
64
+
65
+ ## Verified behaviour
66
+
67
+ A table of probe → result. Paging, search, sort, filters. One row per call you
68
+ actually made.
69
+
70
+ **What the search matches.** Test prefix, mid-word, case, digits, across spaces,
71
+ and one transposed letter. Most app search is a plain case-insensitive substring
72
+ with no tolerance.
73
+
74
+ > A probe using values no record has proves nothing. If a filter returns zero,
75
+ > confirm a matching record exists before concluding anything.
76
+
77
+ | Probe | Result |
78
+ |---|---|
79
+ | | |
80
+
81
+ ## Vocabulary observed
82
+
83
+ Real enum values seen on live records — not the generic ones you would expect.
84
+ Mark which you verified and which came from reading the app bundle.
85
+
86
+ ## Identifiers
87
+
88
+ | Identifier | Produced by | Example |
89
+ |---|---|---|
90
+ | | | |
91
+
92
+ ## Brand (sampled, not guessed)
93
+
94
+ Colour, typeface, greys, base size and radii from the running app's computed
95
+ styles — not from a screenshot. Re-sample per tenant; do not carry values
96
+ across.
97
+
98
+ ## Endpoints seen in the bundle but NOT called
99
+
100
+ Listed so the next person knows they exist **and knows they are unverified**.
101
+
102
+ ## Failures observed
103
+
104
+ | Call | Result | What it means |
105
+ |---|---|---|
106
+ | | | |
107
+
108
+ Record inconclusive results as inconclusive. A 401 on a dead session says
109
+ nothing about whether the endpoint works.
@@ -7,6 +7,8 @@
7
7
  "scripts": {
8
8
  "dev": "foldspace build --watch",
9
9
  "build": "foldspace build",
10
+ "lint": "foldspace lint",
11
+ "typecheck": "tsc --noEmit -p tsconfig.json",
10
12
  "inject": "foldspace inject",
11
13
  "attach": "foldspace attach",
12
14
  "attach:daemon": "foldspace attach --daemon"