@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.
Files changed (120) hide show
  1. package/CLAUDE.md +402 -204
  2. package/README.md +84 -36
  3. package/bin/ask.mjs +19 -9
  4. package/bin/attach.mjs +91 -56
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +141 -0
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +16 -4
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +15 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +102 -47
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/keep-focus.mjs +17 -13
  100. package/src/lint/index.mjs +8 -1
  101. package/src/lint/rules.mjs +240 -43
  102. package/src/observe-core.mjs +163 -34
  103. package/src/published-core.mjs +392 -0
  104. package/src/region.mjs +1 -1
  105. package/src/runtime/agent.ts +16 -12
  106. package/src/runtime/http.ts +25 -15
  107. package/src/runtime/match.ts +1 -1
  108. package/src/runtime/render.ts +4 -4
  109. package/src/transports/README.md +2 -2
  110. package/src/upgrade.mjs +33 -10
  111. package/templates/agent-starter/CLAUDE.md +8 -5
  112. package/templates/agent-starter/README.md +32 -33
  113. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  114. package/templates/agent-starter/agent/actions/index.ts +2 -2
  115. package/templates/agent-starter/agent/constants.ts +4 -3
  116. package/templates/agent-starter/agent/utils.ts +15 -18
  117. package/templates/agent-starter/claude-settings.json +2 -1
  118. package/templates/agent-starter/docs/app-profile.md +19 -14
  119. package/templates/agent-starter/foldspace.dev.json +1 -1
  120. 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("Product ID must be the bare product ID, not an EU-… SDK key.");
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(" npm run attach");
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
  }
@@ -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
- const FRONT_APP_SCRIPT =
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
- // 4s, not less: the first call wakes System Events, which alone can take
15
- // over a second, and a timeout here silently leaves focus on Chrome.
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
- return osascript(FRONT_APP_SCRIPT);
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
- const script = `tell application id "${bundleId.replace(/"/g, "")}" to activate`;
46
- await osascript(script);
50
+ await run("open", ["-b", bundleId]);
47
51
  await new Promise((resolve) => setTimeout(resolve, 400));
48
- await osascript(script);
52
+ await run("open", ["-b", bundleId]);
49
53
  }
@@ -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),
@@ -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 or agent instructions via MCP or Agent Studio; return data only.";
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 copilot operating instructions ('${text.slice(0, 80)}').`,
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
- 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
- });
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 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.",
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 panel saves every rendered card to the conversation history as an image
454
- // drawn with html2canvas, which cannot parse modern colour functions: one such
455
- // value and the saved card is blank ("data:," where the card was - seen on a
456
- // CRM, 2026-09-22). Hex and rgba survive.
457
- const UNSNAPSHOTTABLE_COLOR = /\b(oklch|oklab|lch|lab|color-mix|light-dark)\s*\(/i;
458
-
459
- export function ruleNoUnsnapshottableColor(sourceFile, relative, findings) {
460
- forEachStringLiteral(sourceFile, (node) => {
461
- if (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent)) return;
462
- const text = node.getText(sourceFile);
463
- const match = UNSNAPSHOTTABLE_COLOR.exec(text);
464
- if (!match) return;
465
- findings.push(
466
- finding({
467
- rule: "no-unsnapshottable-color",
468
- severity: "error",
469
- sourceFile,
470
- node,
471
- file: relative,
472
- message: `${match[1]}() in a card: the panel saves cards to history as an image drawn with html2canvas, which cannot read it - the saved card comes out blank.`,
473
- fix: "Use plain hex or rgba. Do not read the app's CSS variables either: on modern apps they resolve to oklch.",
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
- ruleNoUnsnapshottableColor(sourceFile, relative, findings);
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
  }