@foldspace_npm/harness 0.1.26 → 0.1.28
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 +402 -204
- package/README.md +84 -36
- package/bin/ask.mjs +19 -9
- package/bin/attach.mjs +91 -56
- package/bin/badge.mjs +28 -8
- package/bin/cli.mjs +23 -6
- package/bin/inject.mjs +3 -4
- package/bin/observe.mjs +22 -6
- package/bin/published.mjs +141 -0
- package/package.json +6 -2
- package/recipes/INDEX.md +13 -8
- package/recipes/README.md +30 -7
- package/recipes/account-overview/README.md +6 -6
- package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
- package/recipes/account-overview/agent/views/brand.ts +23 -14
- package/recipes/account-overview/agent/views/overview.ts +13 -17
- package/recipes/bottom-bar/README.md +12 -9
- package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
- package/recipes/create-a-record/README.md +97 -0
- package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
- package/recipes/create-a-record/agent/api/projects.ts +62 -0
- package/recipes/create-a-record/agent/projects.ts +145 -0
- package/recipes/create-a-record/agent/views/approval.ts +181 -0
- package/recipes/create-a-record/agent/views/brand.ts +35 -0
- package/recipes/create-a-record/agent/views/fields.ts +252 -0
- package/recipes/create-a-record/agent/views/project-card.ts +253 -0
- package/recipes/create-a-record/agent/views/steps.ts +92 -0
- package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
- package/recipes/create-a-record/fixtures/project.created.json +1 -0
- package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
- package/recipes/create-a-record/recipe.json +13 -0
- package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
- package/recipes/find-by-name/README.md +3 -1
- package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
- package/recipes/lookup-then-view/README.md +73 -0
- package/recipes/lookup-then-view/agent/accounts.ts +174 -0
- package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
- package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
- package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
- package/recipes/lookup-then-view/agent/views/account.ts +183 -0
- package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
- package/recipes/lookup-then-view/agent/views/format.ts +73 -0
- package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
- package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
- package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
- package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
- package/recipes/lookup-then-view/recipe.json +11 -0
- package/recipes/lookup-then-view/studio/find_account.json +10 -0
- package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
- package/recipes/opportunities-at-risk/README.md +5 -5
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
- package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
- package/recipes/pick-from-a-list/README.md +7 -6
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
- package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
- package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
- package/recipes/prepare-for-a-meeting/README.md +4 -4
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
- package/recipes/records-list/README.md +6 -6
- package/recipes/records-list/agent/accounts.ts +3 -1
- package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
- package/recipes/records-list/agent/views/brand.ts +26 -14
- package/recipes/server-rendered-forms/README.md +35 -0
- package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
- package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
- package/recipes/server-rendered-forms/agent/forms.ts +192 -0
- package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
- package/recipes/server-rendered-forms/fixtures/form.html +17 -0
- package/recipes/server-rendered-forms/fixtures/list.html +9 -0
- package/recipes/server-rendered-forms/fixtures/record.html +4 -0
- package/recipes/server-rendered-forms/recipe.json +10 -0
- package/recipes/several-hosts/README.md +10 -13
- package/recipes/several-hosts/agent/hosts.ts +19 -136
- package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
- package/recipes/several-hosts/recipe.json +3 -3
- package/recipes/swap-the-login-method/README.md +16 -5
- package/recipes/swap-the-login-method/agent/utils.ts +52 -3
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
- package/recipes/update-meeting-notes/README.md +10 -4
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
- package/recipes/upload-contacts/README.md +5 -5
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
- package/recipes/upload-contacts/agent/views/brand.ts +24 -14
- package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
- package/recipes/who-is-the-user/README.md +7 -3
- package/src/ask-core.mjs +16 -4
- package/src/attach-daemon.mjs +9 -2
- package/src/attach-helpers.mjs +105 -0
- package/src/attach-preflight.mjs +34 -11
- package/src/badge-core.mjs +1 -1
- package/src/cdp-client.mjs +15 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +102 -47
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/keep-focus.mjs +17 -13
- package/src/lint/index.mjs +8 -1
- package/src/lint/rules.mjs +240 -43
- package/src/observe-core.mjs +163 -34
- package/src/published-core.mjs +392 -0
- package/src/region.mjs +1 -1
- package/src/runtime/agent.ts +16 -12
- package/src/runtime/http.ts +25 -15
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +4 -4
- package/src/transports/README.md +2 -2
- package/src/upgrade.mjs +33 -10
- package/templates/agent-starter/CLAUDE.md +8 -5
- package/templates/agent-starter/README.md +32 -33
- package/templates/agent-starter/agent/actions/_example.ts +4 -4
- package/templates/agent-starter/agent/actions/index.ts +2 -2
- package/templates/agent-starter/agent/constants.ts +4 -3
- package/templates/agent-starter/agent/utils.ts +15 -18
- package/templates/agent-starter/claude-settings.json +2 -1
- package/templates/agent-starter/docs/app-profile.md +19 -14
- package/templates/agent-starter/foldspace.dev.json +1 -1
- package/recipes/several-hosts/fixtures/nav-record.json +0 -5
package/src/init.mjs
CHANGED
|
@@ -137,7 +137,7 @@ function validateIdentifier(value, label) {
|
|
|
137
137
|
function validateProductId(value) {
|
|
138
138
|
const productId = validateIdentifier(value, "Product ID");
|
|
139
139
|
if (/^EU-/i.test(productId)) {
|
|
140
|
-
throw new Error("
|
|
140
|
+
throw new Error("Use the bare product ID, not the SDK key (the one that starts with EU-, in every region).");
|
|
141
141
|
}
|
|
142
142
|
return productId;
|
|
143
143
|
}
|
|
@@ -504,9 +504,11 @@ export async function runInit(argv, options = {}) {
|
|
|
504
504
|
if (!installed) {
|
|
505
505
|
log(" npm install --ignore-scripts");
|
|
506
506
|
}
|
|
507
|
+
log(" # before the first build, CLAUDE.md step 5: a code project's agent/ replaces the scaffold's");
|
|
507
508
|
log(" npm run build");
|
|
508
|
-
log(" npm run inject");
|
|
509
|
-
log("
|
|
509
|
+
log(" npm run inject # the human signs in in that window");
|
|
510
|
+
log(" npx foldspace observe wait-login");
|
|
511
|
+
log(" npx foldspace attach --daemon");
|
|
510
512
|
|
|
511
513
|
return { ...result, installed };
|
|
512
514
|
}
|
package/src/keep-focus.mjs
CHANGED
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
// then types into their live product (seen 2026-09-22). Nothing in CDP stops
|
|
4
4
|
// that; on macOS the fix is to hand focus straight back to whatever app the
|
|
5
5
|
// human was using. Elsewhere this is a no-op.
|
|
6
|
+
//
|
|
7
|
+
// Done with Launch Services (`lsappinfo`, `open -b`), not AppleScript: asking
|
|
8
|
+
// "System Events" which app is in front makes macOS show the customer a
|
|
9
|
+
// permission dialog - "claude wants access to control System Events" - in
|
|
10
|
+
// the middle of their first session (seen 2026-09-30). Launch Services
|
|
11
|
+
// answers the same question, and brings an app forward, with no dialog.
|
|
6
12
|
|
|
7
13
|
import { execFile } from "node:child_process";
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
'tell application "System Events" to get bundle identifier of first application process whose frontmost is true';
|
|
11
|
-
|
|
12
|
-
function osascript(script) {
|
|
15
|
+
function run(command, args, timeout = 4000) {
|
|
13
16
|
return new Promise((resolve) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
execFile("osascript", ["-e", script], { timeout: 4000 }, (error, stdout) => {
|
|
17
|
-
resolve(error ? null : String(stdout).trim());
|
|
17
|
+
execFile(command, args, { timeout }, (error, stdout) => {
|
|
18
|
+
resolve(error ? null : String(stdout));
|
|
18
19
|
});
|
|
19
20
|
});
|
|
20
21
|
}
|
|
@@ -22,7 +23,11 @@ function osascript(script) {
|
|
|
22
23
|
/** Bundle id of the app in front right now, or null (not macOS, or unknown). */
|
|
23
24
|
export async function frontApp() {
|
|
24
25
|
if (process.platform !== "darwin") return null;
|
|
25
|
-
|
|
26
|
+
const asn = (await run("lsappinfo", ["front"]))?.trim();
|
|
27
|
+
if (!asn) return null;
|
|
28
|
+
const info = await run("lsappinfo", ["info", "-only", "bundleid", asn]);
|
|
29
|
+
const match = /"CFBundleIdentifier"="([^"]+)"/.exec(info || "");
|
|
30
|
+
return match ? match[1] : null;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/**
|
|
@@ -39,11 +44,10 @@ export async function withoutStealingFocus(action, { front = frontApp, restore =
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export async function restoreFront(bundleId) {
|
|
42
|
-
if (process.platform !== "darwin" || !bundleId) return;
|
|
47
|
+
if (process.platform !== "darwin" || !bundleId || !/^[\w.-]+$/.test(bundleId)) return;
|
|
43
48
|
// Twice, a beat apart: Chrome's activation lands after the navigation
|
|
44
49
|
// settles, so one immediate restore can be overtaken.
|
|
45
|
-
|
|
46
|
-
await osascript(script);
|
|
50
|
+
await run("open", ["-b", bundleId]);
|
|
47
51
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
48
|
-
await
|
|
52
|
+
await run("open", ["-b", bundleId]);
|
|
49
53
|
}
|
package/src/lint/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
|
|
4
|
-
import { lintActionFile, lintViewsFile, ruleRegistryIntegrity } from "./rules.mjs";
|
|
4
|
+
import { lintActionFile, lintViewsFile, ruleNoTestModeInBundle, ruleRegistryIntegrity } from "./rules.mjs";
|
|
5
5
|
|
|
6
6
|
export const LINT_SCHEMA_VERSION = 1;
|
|
7
7
|
|
|
@@ -44,6 +44,13 @@ export function lintProject(projectDir) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Any file under agent/ can end up in the bundle, including code that runs
|
|
48
|
+
// as soon as the bundle loads, so this rule reads all of it - not only
|
|
49
|
+
// actions and views.
|
|
50
|
+
for (const filePath of listSourceFiles(path.join(projectDir, "agent"))) {
|
|
51
|
+
ruleNoTestModeInBundle(parseFile(filePath), path.relative(projectDir, filePath), findings);
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
ruleRegistryIntegrity({
|
|
48
55
|
indexFile: entry,
|
|
49
56
|
indexSource: parseFile(entry),
|
package/src/lint/rules.mjs
CHANGED
|
@@ -57,7 +57,7 @@ const IMPERATIVE_PATTERNS = [
|
|
|
57
57
|
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
|
|
58
58
|
|
|
59
59
|
const PROMPT_FIX =
|
|
60
|
-
"Put the wording in the action
|
|
60
|
+
"Put the wording in the action's instructions (with the Foldspace tools or in Agent Studio) or in the agent's instructions (Agent Studio only); return data only.";
|
|
61
61
|
|
|
62
62
|
function mergeBindings(fn, sourceFile) {
|
|
63
63
|
return new Map([
|
|
@@ -98,7 +98,7 @@ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, or
|
|
|
98
98
|
sourceFile,
|
|
99
99
|
node: at,
|
|
100
100
|
file: relative,
|
|
101
|
-
message: `${origin} sends the
|
|
101
|
+
message: `${origin} sends the agent operating instructions ('${text.slice(0, 80)}').`,
|
|
102
102
|
fix: PROMPT_FIX,
|
|
103
103
|
}),
|
|
104
104
|
);
|
|
@@ -128,19 +128,29 @@ export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
if (name === "render") {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
131
|
+
// What a card hands to callback, and to readyState (render's 6th
|
|
132
|
+
// parameter), reaches the agent as the card's result - check both.
|
|
133
|
+
const params = fn.parameters || [];
|
|
134
|
+
const readyName =
|
|
135
|
+
params.length >= 6 && ts.isIdentifier(params[5].name) ? params[5].name.text : null;
|
|
136
|
+
for (const [paramName, origin] of [
|
|
137
|
+
[callbackParamName(fn), "callback"],
|
|
138
|
+
[readyName, "readyState"],
|
|
139
|
+
]) {
|
|
140
|
+
if (!paramName) continue;
|
|
141
|
+
forEachCallNamed(fn, paramName, (call) => {
|
|
142
|
+
const arg = call.arguments[0];
|
|
143
|
+
if (!arg) return;
|
|
144
|
+
inspectReturnedObject(
|
|
145
|
+
resolveNode(arg, bindings),
|
|
146
|
+
bindings,
|
|
147
|
+
sourceFile,
|
|
148
|
+
relative,
|
|
149
|
+
findings,
|
|
150
|
+
origin,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
144
154
|
}
|
|
145
155
|
});
|
|
146
156
|
}
|
|
@@ -208,8 +218,8 @@ function reportEmoji(node, sourceFile, relative, findings) {
|
|
|
208
218
|
sourceFile,
|
|
209
219
|
node,
|
|
210
220
|
file: relative,
|
|
211
|
-
message: "Emoji in handler or
|
|
212
|
-
fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the
|
|
221
|
+
message: "Emoji in handler or card copy reads as off-brand in a product UI that has none.",
|
|
222
|
+
fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the Foldspace tools cannot write that field, so each new action's Studio instructions must carry the line too.",
|
|
213
223
|
}),
|
|
214
224
|
);
|
|
215
225
|
}
|
|
@@ -450,30 +460,218 @@ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, pro
|
|
|
450
460
|
}
|
|
451
461
|
}
|
|
452
462
|
|
|
453
|
-
// The
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
463
|
+
// The card contract of the page SDK. A card is a view card or an input card.
|
|
464
|
+
// An input card's render uses one of its 4th to 6th parameters - callback,
|
|
465
|
+
// cancel, readyState - and the agent waits until callback or cancel runs, or
|
|
466
|
+
// until readyState reports. A view card uses none of them: as soon as render
|
|
467
|
+
// returns, the agent is told it was shown, with everything execute returned.
|
|
468
|
+
// awaitUserInput is never needed. The chat panel ignores it; in headless mode
|
|
469
|
+
// awaitUserInput: true forces the wait, so on a view card the agent waits for
|
|
470
|
+
// a callback that never comes.
|
|
471
|
+
//
|
|
472
|
+
// "Uses" is read the way the SDK reads render in the built bundle, where
|
|
473
|
+
// parameters are renamed and types are erased: the parameter's name anywhere
|
|
474
|
+
// in the body, nested functions included, but not as a property name
|
|
475
|
+
// (`host.callback`, `{ callback: 1 }`) and not inside a type. A destructured
|
|
476
|
+
// parameter, a `...rest` parameter or `arguments` count as used. A TypeScript
|
|
477
|
+
// `this` parameter is erased, so it takes no position.
|
|
478
|
+
const CARD_PARAMS = ["callback", "cancel", "readyState"];
|
|
479
|
+
|
|
480
|
+
function mentions(fn, test) {
|
|
481
|
+
let found = false;
|
|
482
|
+
function walk(node) {
|
|
483
|
+
if (found) return;
|
|
484
|
+
if (ts.isIdentifier(node) && test(node.text)) {
|
|
485
|
+
const parent = node.parent;
|
|
486
|
+
const isName =
|
|
487
|
+
parent &&
|
|
488
|
+
(ts.isPropertyAccessExpression(parent) ||
|
|
489
|
+
ts.isPropertyAssignment(parent) ||
|
|
490
|
+
ts.isMethodDeclaration(parent) ||
|
|
491
|
+
ts.isPropertyDeclaration(parent) ||
|
|
492
|
+
ts.isGetAccessorDeclaration(parent) ||
|
|
493
|
+
ts.isSetAccessorDeclaration(parent)) &&
|
|
494
|
+
parent.name === node;
|
|
495
|
+
const isKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
|
|
496
|
+
let inType = false;
|
|
497
|
+
for (let up = parent; up && up !== fn; up = up.parent) {
|
|
498
|
+
if (ts.isTypeNode(up) && !ts.isExpressionWithTypeArguments(up)) {
|
|
499
|
+
inType = true;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (!isName && !isKey && !inType) {
|
|
504
|
+
found = true;
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
ts.forEachChild(node, walk);
|
|
509
|
+
}
|
|
510
|
+
if (fn.body) walk(fn.body);
|
|
511
|
+
return found;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** What the SDK concludes about a render function: does the agent wait, and which card parameters are declared but unused. */
|
|
515
|
+
function readRender(fn) {
|
|
516
|
+
const params = fn.parameters.filter((param) => !(ts.isIdentifier(param.name) && param.name.text === "this"));
|
|
517
|
+
const cardParams = params.slice(3, 3 + CARD_PARAMS.length);
|
|
518
|
+
const restOrArguments = params.some((param) => param.dotDotDotToken) || mentions(fn, (name) => name === "arguments");
|
|
519
|
+
const destructured = cardParams.some((param) => !ts.isIdentifier(param.name));
|
|
520
|
+
const names = cardParams.filter((param) => ts.isIdentifier(param.name)).map((param) => param.name.text);
|
|
521
|
+
const used = names.filter((name) => mentions(fn, (text) => text === name));
|
|
522
|
+
const waits = restOrArguments || destructured || used.length > 0;
|
|
523
|
+
return { waits, declaredUnused: !waits && names.length > 0 ? names : [] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// The function `render` names: inline, a method, or - for `render` and
|
|
527
|
+
// `render: renderCard` - a `function` or `const` of that name in the same file.
|
|
528
|
+
function declaredFunction(sourceFile, name) {
|
|
529
|
+
let found = null;
|
|
530
|
+
function walk(node) {
|
|
531
|
+
if (found) return;
|
|
532
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
533
|
+
found = node;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && isFunctionLike(node.initializer)) {
|
|
537
|
+
found = node.initializer;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
ts.forEachChild(node, walk);
|
|
541
|
+
}
|
|
542
|
+
walk(sourceFile);
|
|
543
|
+
return found;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function renderFunction(sourceFile, member) {
|
|
547
|
+
if (ts.isMethodDeclaration(member)) return member;
|
|
548
|
+
if (ts.isShorthandPropertyAssignment(member)) return declaredFunction(sourceFile, member.name.text);
|
|
549
|
+
let value = member.initializer;
|
|
550
|
+
while (
|
|
551
|
+
value &&
|
|
552
|
+
(ts.isParenthesizedExpression(value) ||
|
|
553
|
+
ts.isAsExpression(value) ||
|
|
554
|
+
ts.isSatisfiesExpression(value) ||
|
|
555
|
+
ts.isNonNullExpression(value) ||
|
|
556
|
+
ts.isTypeAssertionExpression(value))
|
|
557
|
+
)
|
|
558
|
+
value = value.expression;
|
|
559
|
+
if (isFunctionLike(value)) return value;
|
|
560
|
+
if (value && ts.isIdentifier(value)) return declaredFunction(sourceFile, value.text);
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function ruleCardContract(sourceFile, relative, findings) {
|
|
565
|
+
function walk(node) {
|
|
566
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
567
|
+
const members = node.properties;
|
|
568
|
+
const render = members.find(
|
|
569
|
+
(member) =>
|
|
570
|
+
propertyName(member) === "render" &&
|
|
571
|
+
(ts.isPropertyAssignment(member) || ts.isMethodDeclaration(member) || ts.isShorthandPropertyAssignment(member)),
|
|
572
|
+
);
|
|
573
|
+
const fn = render ? renderFunction(sourceFile, render) : null;
|
|
574
|
+
if (fn) {
|
|
575
|
+
const { waits, declaredUnused } = readRender(fn);
|
|
576
|
+
const flag = members.find((member) => propertyName(member) === "awaitUserInput");
|
|
577
|
+
const flagTrue = flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.TrueKeyword;
|
|
578
|
+
if (flagTrue && !waits) {
|
|
579
|
+
findings.push(
|
|
580
|
+
finding({
|
|
581
|
+
rule: "card-contract",
|
|
582
|
+
severity: "error",
|
|
583
|
+
sourceFile,
|
|
584
|
+
node: flag.name,
|
|
585
|
+
file: relative,
|
|
586
|
+
message:
|
|
587
|
+
"A view card (render uses neither callback nor cancel) sets awaitUserInput: true. The chat panel ignores it, but in headless mode the agent then waits for a callback that never comes.",
|
|
588
|
+
fix: "Remove awaitUserInput. A view card is reported to the agent as shown, with what execute returned.",
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (flag && !waits && !flagTrue) {
|
|
593
|
+
findings.push(
|
|
594
|
+
finding({
|
|
595
|
+
rule: "card-contract",
|
|
596
|
+
severity: "warning",
|
|
597
|
+
sourceFile,
|
|
598
|
+
node: flag.name,
|
|
599
|
+
file: relative,
|
|
600
|
+
message:
|
|
601
|
+
"A view card does not need awaitUserInput: render uses neither callback nor cancel, so the agent is told the card was shown, with what execute returned.",
|
|
602
|
+
fix: "Remove awaitUserInput.",
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (flag && waits) {
|
|
607
|
+
findings.push(
|
|
608
|
+
finding({
|
|
609
|
+
rule: "card-contract",
|
|
610
|
+
severity: "warning",
|
|
611
|
+
sourceFile,
|
|
612
|
+
node: flag.name,
|
|
613
|
+
file: relative,
|
|
614
|
+
message:
|
|
615
|
+
"An input card does not need awaitUserInput: render uses callback, cancel or readyState, and that is what makes the agent wait.",
|
|
616
|
+
fix: 'Remove awaitUserInput. Then check that every path calls callback or cancel - choosing, "none of these", closing the card, failing. When the conversation should go on while the user decides, call readyState(value) once the card is drawn.',
|
|
617
|
+
}),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (declaredUnused.length > 0) {
|
|
621
|
+
const list =
|
|
622
|
+
declaredUnused.length === 1
|
|
623
|
+
? declaredUnused[0]
|
|
624
|
+
: `${declaredUnused.slice(0, -1).join(", ")} and ${declaredUnused.at(-1)}`;
|
|
625
|
+
const unused = declaredUnused.length === 1 ? "never uses it" : "uses none of them";
|
|
626
|
+
findings.push(
|
|
627
|
+
finding({
|
|
628
|
+
rule: "card-contract",
|
|
629
|
+
severity: "warning",
|
|
630
|
+
sourceFile,
|
|
631
|
+
node: render.name,
|
|
632
|
+
file: relative,
|
|
633
|
+
message: `render declares ${list} but ${unused} - the SDK treats the card as a view card: the agent is told it was shown, with what execute returned.`,
|
|
634
|
+
fix: `For a view card, remove ${list} from render. For an input card, call callback or cancel on every path, or readyState(value) once the card is drawn.`,
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
ts.forEachChild(node, walk);
|
|
641
|
+
}
|
|
642
|
+
walk(sourceFile);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Test mode belongs to `attach --test-mode`, which never touches the bundle.
|
|
646
|
+
// A call in agent code that turns it on can ship to production and mark real
|
|
647
|
+
// users' conversations as test traffic. A call to `setTestMode` by name is
|
|
648
|
+
// refused unless its first argument is the literal `false`. A call to
|
|
649
|
+
// `armAllInstances` by name is always refused: even with `false` it sends a
|
|
650
|
+
// partial `setConfiguration` to every instance.
|
|
651
|
+
export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
|
|
652
|
+
const walk = (node) => {
|
|
653
|
+
if (ts.isCallExpression(node)) {
|
|
654
|
+
const callee = node.expression;
|
|
655
|
+
const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : "";
|
|
656
|
+
const first = node.arguments[0];
|
|
657
|
+
const setTestModeOff = name === "setTestMode" && first && first.kind === ts.SyntaxKind.FalseKeyword;
|
|
658
|
+
if ((name === "setTestMode" || name === "armAllInstances") && !setTestModeOff) {
|
|
659
|
+
findings.push(
|
|
660
|
+
finding({
|
|
661
|
+
rule: "no-test-mode-in-bundle",
|
|
662
|
+
severity: "error",
|
|
663
|
+
sourceFile,
|
|
664
|
+
node,
|
|
665
|
+
file: relative,
|
|
666
|
+
message: `\`${name}\` does not belong in agent code: any file in \`agent/\` can ship to production, where test mode would stop real users' conversations being tracked.`,
|
|
667
|
+
fix: "Remove it. Test mode is only for an agent that already has real users, and only through `npx foldspace attach --test-mode`, which never touches the bundle. For an agent with no real users yet, leave test mode off.",
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
ts.forEachChild(node, walk);
|
|
673
|
+
};
|
|
674
|
+
walk(sourceFile);
|
|
477
675
|
}
|
|
478
676
|
|
|
479
677
|
export function lintActionFile(filePath, relative, findings) {
|
|
@@ -482,11 +680,10 @@ export function lintActionFile(filePath, relative, findings) {
|
|
|
482
680
|
ruleNoStaticAgentPrompt(sourceFile, relative, findings);
|
|
483
681
|
ruleNoRunTaskPrompt(sourceFile, relative, findings);
|
|
484
682
|
ruleNoEmoji(sourceFile, relative, findings);
|
|
485
|
-
|
|
683
|
+
ruleCardContract(sourceFile, relative, findings);
|
|
486
684
|
}
|
|
487
685
|
|
|
488
686
|
export function lintViewsFile(filePath, relative, findings) {
|
|
489
687
|
const sourceFile = parseFile(filePath);
|
|
490
688
|
ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
|
|
491
|
-
ruleNoUnsnapshottableColor(sourceFile, relative, findings);
|
|
492
689
|
}
|