@foldspace_npm/harness 0.1.27 → 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 +301 -188
- package/README.md +54 -36
- package/bin/ask.mjs +9 -7
- package/bin/attach.mjs +80 -51
- 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 +1 -1
- 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 +3 -3
- 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 +14 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +65 -46
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/lint/index.mjs +8 -1
- package/src/lint/rules.mjs +240 -43
- package/src/observe-core.mjs +163 -34
- 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 +1 -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/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
|
}
|
package/src/observe-core.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export function isSameSite(url, pageUrl, hosts = []) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* One product often lives on several hosts (www.,
|
|
47
|
+
* One product often lives on several hosts (www., reports., admin. ...), and a
|
|
48
48
|
* project only works on the hosts it names. Given addresses seen from a page,
|
|
49
49
|
* say which hosts look like the same product but are NOT covered, and the one
|
|
50
50
|
* pattern that would cover them. The site is an approximation (see siteOf), so
|
|
@@ -81,7 +81,7 @@ export function uncoveredSiblings(urls, pageUrl, hosts = []) {
|
|
|
81
81
|
hint:
|
|
82
82
|
`This product also lives on ${found.slice(0, 4).map((entry) => entry.host).join(", ")}` +
|
|
83
83
|
`${found.length > 4 ? ` and ${found.length - 4} more` : ""}, which this project does not cover. ` +
|
|
84
|
-
`To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach
|
|
84
|
+
`To work there, add "${pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with.`,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -132,7 +132,7 @@ export function gotoDestination(input, pageUrl, hosts = []) {
|
|
|
132
132
|
throw new Error(
|
|
133
133
|
`--goto: ${target.hostname} is not one of this project's hosts (${hosts.join(", ") || "none named"}). ` +
|
|
134
134
|
(coverage.pattern
|
|
135
|
-
? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach
|
|
135
|
+
? `If it is part of the product, add "${coverage.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with. If it is not, --goto a screen on this project's hosts.`
|
|
136
136
|
: "observe only opens screens of the product this project is for."),
|
|
137
137
|
);
|
|
138
138
|
}
|
|
@@ -398,6 +398,11 @@ export function rankCandidates(candidates, words = [], max = 8) {
|
|
|
398
398
|
|
|
399
399
|
const WRITE_FLAGS = ["method", "data", "body", "header", "headers", "json", "post", "put", "delete"];
|
|
400
400
|
|
|
401
|
+
/** True for an option that would make `observe read` write (`--method`, `--body`, ...). */
|
|
402
|
+
export function isWriteFlag(option) {
|
|
403
|
+
return WRITE_FLAGS.includes(String(option || "").replace(/^--/, ""));
|
|
404
|
+
}
|
|
405
|
+
|
|
401
406
|
/**
|
|
402
407
|
* `observe read` is GET only. There is deliberately no way to pick a method,
|
|
403
408
|
* add a header or send a body: anything else belongs in an action handler the
|
|
@@ -407,7 +412,7 @@ export function refuseWriteFlags(flags = {}) {
|
|
|
407
412
|
const asked = WRITE_FLAGS.filter((name) => name in flags);
|
|
408
413
|
if (asked.length) {
|
|
409
414
|
throw new Error(
|
|
410
|
-
`observe read is GET only
|
|
415
|
+
`observe read is GET only - it never sets a method, a header or a body (refused: ${asked
|
|
411
416
|
.map((name) => `--${name}`)
|
|
412
417
|
.join(", ")}).`,
|
|
413
418
|
);
|
|
@@ -706,8 +711,9 @@ export function looksLoggedIn({ url, hasPasswordField = false, hosts = [] }) {
|
|
|
706
711
|
|
|
707
712
|
/** Computed colours come back as rgb()/rgba(); anything else passes through. */
|
|
708
713
|
// oklch / oklab, as modern apps write their palettes and as Chrome reports
|
|
709
|
-
// them back, converted to sRGB
|
|
710
|
-
//
|
|
714
|
+
// them back, converted to sRGB hex like rgb() and rgba(): brandFile checks
|
|
715
|
+
// contrast and works out its other keys from hex. lab(), lch() and color()
|
|
716
|
+
// pass through; brandFile writes a default in their place.
|
|
711
717
|
function okToHex(text) {
|
|
712
718
|
const match = /^(oklch|oklab)\(\s*([\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(text);
|
|
713
719
|
if (!match) return null;
|
|
@@ -770,57 +776,180 @@ function luminance(hex) {
|
|
|
770
776
|
|
|
771
777
|
const isHex = (value) => /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(String(value || ""));
|
|
772
778
|
|
|
779
|
+
/** `share` of the way from one #RRGGBB to another, channel by channel. */
|
|
780
|
+
function mixHex(from, to, share) {
|
|
781
|
+
const channels = (hex) => [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16));
|
|
782
|
+
const target = channels(to);
|
|
783
|
+
return `#${channels(from)
|
|
784
|
+
.map((value, i) => Math.round(value + (target[i] - value) * share).toString(16).padStart(2, "0"))
|
|
785
|
+
.join("")
|
|
786
|
+
.toUpperCase()}`;
|
|
787
|
+
}
|
|
788
|
+
|
|
773
789
|
/**
|
|
774
790
|
* `agent/views/brand.ts` from what `observe styles` sampled: the app's own
|
|
775
|
-
* accent, text, border, font and
|
|
776
|
-
*
|
|
791
|
+
* accent, text, muted text, border, font, radius and text size, on white (the
|
|
792
|
+
* panel's default background), plus every other key a recipe card reads - a
|
|
793
|
+
* fixed colour (surface2 and the status colours), one worked out from the
|
|
794
|
+
* sampled ones, or another name for one of them. A see-through text, muted or
|
|
795
|
+
* border colour is flattened onto the white; a see-through accent keeps its
|
|
796
|
+
* colour at full strength, so a tinted button still gives the app's own accent.
|
|
797
|
+
* A text, muted, border or accent colour still too light to read on white falls
|
|
798
|
+
* back to its default and says so. A dark app keeps its font, radius, text size
|
|
799
|
+
* and (unless it is too light) its accent; its light-on-dark text and borders
|
|
777
800
|
* would vanish on white, so those fall back to neutral greys and say so.
|
|
778
801
|
*/
|
|
779
|
-
export function brandFile(styles, { url, date
|
|
802
|
+
export function brandFile(styles, { url, date } = {}) {
|
|
780
803
|
const notes = [];
|
|
781
804
|
const pick = (value, fallback, name) => {
|
|
782
805
|
const hex = value ? toHex(value) : null; // rgb / oklch / oklab -> hex; anything else stays as it was
|
|
783
806
|
if (isHex(hex)) return hex.slice(0, 7).toUpperCase();
|
|
784
|
-
if (value) notes.push(`${name}: sampled "${value}"
|
|
807
|
+
if (value) notes.push(`${name}: sampled "${value}" could not be converted to hex - using ${fallback}`);
|
|
785
808
|
return fallback;
|
|
786
809
|
};
|
|
810
|
+
// A see-through colour (rgba, or hex with alpha) is drawn on the panel's
|
|
811
|
+
// white, so flatten it onto white: dropping the alpha would turn an
|
|
812
|
+
// rgba(0, 0, 0, 0.12) border, and every key worked out from it, black.
|
|
813
|
+
const onPanel = (value, fallback, name) => {
|
|
814
|
+
const hex = value ? toHex(value) : null;
|
|
815
|
+
const alpha = isHex(hex) && hex.length === 9 ? parseInt(hex.slice(7), 16) / 255 : 1;
|
|
816
|
+
return mixHex("#FFFFFF", pick(value, fallback, name), alpha);
|
|
817
|
+
};
|
|
787
818
|
const background = pick(styles.background, "#FFFFFF", "background");
|
|
788
819
|
const darkApp = (luminance(background) ?? 1) < 0.4;
|
|
789
|
-
if (darkApp) notes.push(`the app is dark (${background}); cards sit in the white
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const
|
|
820
|
+
if (darkApp) notes.push(`the app is dark (${background}); cards sit in the panel, white by default, so text, muted and border are neutral greys`);
|
|
821
|
+
// A colour too light to read on white - white nav text on a light app, a
|
|
822
|
+
// white button on a dark one - falls back to its default, and says so.
|
|
823
|
+
const readable = (resolve, value, fallback, name, limit) => {
|
|
824
|
+
const hex = resolve(value, fallback, name);
|
|
825
|
+
if ((luminance(hex) ?? 0) <= limit) return hex;
|
|
826
|
+
notes.push(`${name}: sampled "${value}" would not read on white - using ${fallback}`);
|
|
827
|
+
return fallback;
|
|
828
|
+
};
|
|
829
|
+
const text = darkApp ? "#111827" : readable(onPanel, styles.text, "#111827", "text", 0.6);
|
|
830
|
+
const muted = darkApp ? "#6B7280" : readable(onPanel, styles.textMuted, "#6B7280", "textMuted", 0.75);
|
|
831
|
+
const border = darkApp ? "#E5E7EB" : readable(onPanel, styles.border, "#E5E7EB", "border", 0.97);
|
|
793
832
|
const buttonBackground = styles.primaryButton?.background;
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const
|
|
833
|
+
const accentSample = buttonBackground || styles.link;
|
|
834
|
+
// The accent keeps its colour at full strength: a tinted button such as
|
|
835
|
+
// rgba(50, 72, 242, 0.08) still gives the app's own blue, which the cards
|
|
836
|
+
// draw white text on.
|
|
837
|
+
const accent = readable(pick, accentSample, "#3248F2", "accent", 0.85);
|
|
838
|
+
// The accent is the app's only when the sample could be converted to hex
|
|
839
|
+
// and reads on white; otherwise a NOTE says why it is the default.
|
|
840
|
+
const accentFromApp = Boolean(accentSample) && !notes.some((note) => note.startsWith("accent:"));
|
|
841
|
+
const accentSource = !accentSample
|
|
842
|
+
? "a default blue (nothing sampled)"
|
|
843
|
+
: !accentFromApp
|
|
844
|
+
? "a default blue (see the NOTE on accent)"
|
|
845
|
+
: buttonBackground
|
|
846
|
+
? "the app's primary button"
|
|
847
|
+
: "the app's links";
|
|
848
|
+
// Only a solid button's text is kept, and only when the accent is that
|
|
849
|
+
// button's: a see-through button's text was chosen for its tint.
|
|
850
|
+
const buttonHex = toHex(buttonBackground);
|
|
851
|
+
const buttonText = accentFromApp && isHex(buttonHex) && buttonHex.length === 7 ? styles.primaryButton?.color : null;
|
|
852
|
+
const accentText = pick(buttonText, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
|
|
799
853
|
const font = String(styles.fontFamily || "").replace(/["'\\]/g, "").trim() || "Roboto, sans-serif";
|
|
800
854
|
const radius = /^\d+(\.\d+)?px$/.test(String(styles.boxRadius || "")) ? styles.boxRadius : "8px";
|
|
855
|
+
// The app's most used text size, kept to 12-16px so a card fits the panel.
|
|
856
|
+
const sampledSize = /^(\d+(?:\.\d+)?)px$/.exec(String(styles.fontSize || ""));
|
|
857
|
+
const base = sampledSize ? Math.min(16, Math.max(12, Math.round(Number(sampledSize[1])))) : 14;
|
|
858
|
+
const white = "#FFFFFF";
|
|
859
|
+
const black = "#000000";
|
|
860
|
+
const good = "#047857";
|
|
861
|
+
const warn = "#B45309";
|
|
862
|
+
const derived = {
|
|
863
|
+
textStrong: mixHex(text, black, 0.5),
|
|
864
|
+
faint: mixHex(muted, white, 0.35),
|
|
865
|
+
row: mixHex(border, white, 0.75),
|
|
866
|
+
tableHead: mixHex(border, white, 0.5),
|
|
867
|
+
accentDeep: mixHex(accent, black, 0.15),
|
|
868
|
+
accentSoft: mixHex(accent, white, 0.9),
|
|
869
|
+
headerStart: mixHex(accent, white, 0.92),
|
|
870
|
+
};
|
|
871
|
+
// One shape for every card: each recipe's brand.ts is a placeholder with
|
|
872
|
+
// these keys, in this order.
|
|
873
|
+
const values = {
|
|
874
|
+
surface: white,
|
|
875
|
+
surface2: "#F5F6FA",
|
|
876
|
+
text,
|
|
877
|
+
muted,
|
|
878
|
+
border,
|
|
879
|
+
accent,
|
|
880
|
+
accentText,
|
|
881
|
+
font,
|
|
882
|
+
radius,
|
|
883
|
+
size: { base: `${base}px`, small: `${base - 2}px` },
|
|
884
|
+
...derived,
|
|
885
|
+
good,
|
|
886
|
+
warn,
|
|
887
|
+
warnInk: "#FEF3C7",
|
|
888
|
+
bad: "#B91C1C",
|
|
889
|
+
primary: accent,
|
|
890
|
+
tint: derived.tableHead,
|
|
891
|
+
disabled: derived.faint,
|
|
892
|
+
money: good,
|
|
893
|
+
ok: good,
|
|
894
|
+
deals: accent,
|
|
895
|
+
contacts: derived.accentDeep,
|
|
896
|
+
cases: warn,
|
|
897
|
+
};
|
|
898
|
+
const groups = {
|
|
899
|
+
textStrong: "Worked out from the values above.",
|
|
900
|
+
good: "Status colours: fixed, so green, amber and red mean the same in every app.",
|
|
901
|
+
primary: "Other names some cards use for the colours above; a new card uses the original key.",
|
|
902
|
+
};
|
|
903
|
+
const comments = {
|
|
904
|
+
surface2: "a second background: a fixed light grey",
|
|
905
|
+
accentText: isHex(toHex(buttonText))
|
|
906
|
+
? "text on accent: the app's button text"
|
|
907
|
+
: "text on accent: white, or near-black when accent is light",
|
|
908
|
+
size: sampledSize
|
|
909
|
+
? "base: the app's most used text size, kept to 12-16px; small: 2px less"
|
|
910
|
+
: "base: 14px (no text size was sampled); small: 2px less",
|
|
911
|
+
textStrong: "names and headings: text, halfway to black",
|
|
912
|
+
faint: "hints and empty states: muted, 35% toward white",
|
|
913
|
+
row: "row backgrounds and alternate rows: border, 75% toward white",
|
|
914
|
+
tableHead: "table heads and summary boxes: border, halfway to white",
|
|
915
|
+
accentDeep: "gradients and hover: accent, 15% toward black",
|
|
916
|
+
accentSoft: "chips, selected rows and focus rings: accent, 90% toward white",
|
|
917
|
+
headerStart: "card headers and stage tags: accent, 92% toward white",
|
|
918
|
+
good: "done, valid, amounts",
|
|
919
|
+
warn: "late, at risk, needs a look",
|
|
920
|
+
warnInk: "the light amber behind a warning tag",
|
|
921
|
+
bad: "failed, not valid, critical",
|
|
922
|
+
primary: "same as accent",
|
|
923
|
+
tint: "pick-from-a-list's head and selected row: same as tableHead",
|
|
924
|
+
disabled: "a button that cannot be pressed: same as faint",
|
|
925
|
+
money: "amounts: same as good",
|
|
926
|
+
ok: "same as good",
|
|
927
|
+
deals: "the open-deals count: same as accent",
|
|
928
|
+
contacts: "the contacts count: same as accentDeep",
|
|
929
|
+
cases: "the open-cases count: same as warn",
|
|
930
|
+
};
|
|
931
|
+
const literal = (key, value) =>
|
|
932
|
+
key === "size" ? `{ base: "${value.base}", small: "${value.small}" }` : key === "font" ? `'${value}'` : `"${value}"`;
|
|
801
933
|
const stamp = `${url || "the app"}${date ? ` on ${date}` : ""}`;
|
|
802
934
|
const lines = [
|
|
803
935
|
`// Brand reference for this product's cards. Sampled from ${stamp} with`,
|
|
804
|
-
"// `foldspace observe styles --write`. Cards live in the agent's panel,
|
|
805
|
-
"// white, so
|
|
806
|
-
"// are the app's own
|
|
807
|
-
"//
|
|
808
|
-
"//
|
|
936
|
+
"// `npx foldspace observe styles --write`. Cards live in the agent's panel,",
|
|
937
|
+
"// which is white by default, so surface is white. text, muted, border, accent,",
|
|
938
|
+
"// font, radius and size are the app's own, or a default where they were not",
|
|
939
|
+
"// sampled or would not read on white; every other key's comment, or the",
|
|
940
|
+
"// heading above it, says where it comes from. Keep every key, so any recipe",
|
|
941
|
+
"// card can use this file.",
|
|
942
|
+
"// Re-run the command to re-sample; do not edit by eye.",
|
|
809
943
|
`// accent: ${accentSource}.`,
|
|
810
944
|
...notes.map((note) => `// NOTE ${note}.`),
|
|
811
945
|
"",
|
|
812
946
|
"export const brand = {",
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
` border: "${border}",`,
|
|
818
|
-
` accent: "${accent}",`,
|
|
819
|
-
` accentText: "${accentText}",`,
|
|
820
|
-
` font: '${font}',`,
|
|
821
|
-
` radius: "${radius}",`,
|
|
947
|
+
...Object.entries(values).flatMap(([key, value]) => [
|
|
948
|
+
...(groups[key] ? [` // ${groups[key]}`] : []),
|
|
949
|
+
` ${key}: ${literal(key, value)},${comments[key] ? ` // ${comments[key]}` : ""}`,
|
|
950
|
+
]),
|
|
822
951
|
"} as const;",
|
|
823
952
|
"",
|
|
824
953
|
];
|
|
825
|
-
return { content: lines.join("\n"), notes, values
|
|
954
|
+
return { content: lines.join("\n"), notes, values };
|
|
826
955
|
}
|
package/src/region.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// points at that region's agent, recording and tracking hosts - the US SDK on
|
|
8
8
|
// an EU page talks to a US backend that does not have the EU agents.
|
|
9
9
|
//
|
|
10
|
-
// `server`: the
|
|
10
|
+
// `server`: the Foldspace tools' server name in the editor, as the region's playbook
|
|
11
11
|
// installs it. Distinct per region, so both can sit side by side.
|
|
12
12
|
export const REGIONS = Object.freeze({
|
|
13
13
|
us: Object.freeze({
|
package/src/runtime/agent.ts
CHANGED
|
@@ -6,7 +6,7 @@ let cached: any | null = null;
|
|
|
6
6
|
* Foldspace SDK handle for this agent's **overlay** instance.
|
|
7
7
|
*
|
|
8
8
|
* Takes no `mode` on purpose: `foldspace.agent({ apiName })` returns the overlay
|
|
9
|
-
* handle. On an app that embeds the
|
|
9
|
+
* handle. On an app that embeds the agent in-page that is not the instance
|
|
10
10
|
* serving the chat, so arming only it succeeds and leaves real conversations
|
|
11
11
|
* untagged. For a specific instance, enumerate `window.foldspace.agentIds()`
|
|
12
12
|
* (`"<mode>-<apiName>"`) and call `foldspace.agent({ apiName, mode })`.
|
|
@@ -25,20 +25,24 @@ export function getAgent(): any | null {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* Turns test mode on or off (`testMode`) on every Foldspace instance on the
|
|
29
|
+
* page (`window.foldspace.agentIds()`) and turns on their remote actions: SDK
|
|
30
|
+
* `setTestMode(testMode)` and `setConfiguration({ remoteActionsSettings })`.
|
|
29
31
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* It does not belong in agent code: any file in `agent/` can ship to
|
|
33
|
+
* production, where test mode would stop real users' conversations being
|
|
34
|
+
* tracked. Leave it out even with `false`, which lint refuses too: it still
|
|
35
|
+
* calls `setConfiguration` on every instance, and a partial `setConfiguration`
|
|
36
|
+
* can shrink the agent's panel to 0×0. Test mode is only for an agent that
|
|
37
|
+
* already has real users, and only through `npx foldspace attach --test-mode`,
|
|
38
|
+
* which never touches the bundle. For an agent with no real users yet, leave
|
|
39
|
+
* test mode off.
|
|
34
40
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* @param testMode - When true, mark conversations as test traffic
|
|
39
|
-
* @returns Instance ids that armed vs failed
|
|
41
|
+
* @param testMode - `true` marks new conversations on this page as test
|
|
42
|
+
* traffic; `false` (the default) turns that off.
|
|
43
|
+
* @returns The instance ids it set (`armed`) and the ones that threw (`failed`)
|
|
40
44
|
*/
|
|
41
|
-
export function armAllInstances(testMode =
|
|
45
|
+
export function armAllInstances(testMode = false): {
|
|
42
46
|
armed: string[];
|
|
43
47
|
failed: string[];
|
|
44
48
|
} {
|