@foldspace_npm/harness 0.1.27 → 0.1.29

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 (119) hide show
  1. package/CLAUDE.md +301 -188
  2. package/README.md +54 -36
  3. package/bin/ask.mjs +9 -7
  4. package/bin/attach.mjs +80 -51
  5. package/bin/badge.mjs +28 -8
  6. package/bin/build.ts +19 -6
  7. package/bin/cli.mjs +23 -6
  8. package/bin/inject.mjs +3 -4
  9. package/bin/observe.mjs +22 -6
  10. package/bin/published.mjs +1 -1
  11. package/package.json +6 -2
  12. package/recipes/INDEX.md +13 -8
  13. package/recipes/README.md +30 -7
  14. package/recipes/account-overview/README.md +6 -6
  15. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  16. package/recipes/account-overview/agent/views/brand.ts +23 -14
  17. package/recipes/account-overview/agent/views/overview.ts +13 -17
  18. package/recipes/bottom-bar/README.md +12 -9
  19. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  20. package/recipes/create-a-record/README.md +97 -0
  21. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  22. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  23. package/recipes/create-a-record/agent/projects.ts +145 -0
  24. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  25. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  26. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  27. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  28. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  29. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  30. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  31. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  32. package/recipes/create-a-record/recipe.json +13 -0
  33. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  34. package/recipes/find-by-name/README.md +3 -1
  35. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  36. package/recipes/lookup-then-view/README.md +73 -0
  37. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  38. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  39. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  40. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  41. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  42. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  43. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  44. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  45. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  46. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  47. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  48. package/recipes/lookup-then-view/recipe.json +11 -0
  49. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  50. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  51. package/recipes/opportunities-at-risk/README.md +5 -5
  52. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  53. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  54. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  55. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  56. package/recipes/pick-from-a-list/README.md +7 -6
  57. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  58. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  59. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  60. package/recipes/prepare-for-a-meeting/README.md +4 -4
  61. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  62. package/recipes/records-list/README.md +6 -6
  63. package/recipes/records-list/agent/accounts.ts +3 -1
  64. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  65. package/recipes/records-list/agent/views/brand.ts +26 -14
  66. package/recipes/server-rendered-forms/README.md +35 -0
  67. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  68. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  69. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  70. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  71. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  72. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  73. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  74. package/recipes/server-rendered-forms/recipe.json +10 -0
  75. package/recipes/several-hosts/README.md +10 -13
  76. package/recipes/several-hosts/agent/hosts.ts +19 -136
  77. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  78. package/recipes/several-hosts/recipe.json +3 -3
  79. package/recipes/swap-the-login-method/README.md +16 -5
  80. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  81. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  82. package/recipes/update-meeting-notes/README.md +10 -4
  83. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  84. package/recipes/upload-contacts/README.md +5 -5
  85. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  86. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  87. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  88. package/recipes/who-is-the-user/README.md +7 -3
  89. package/src/ask-core.mjs +3 -3
  90. package/src/attach-daemon.mjs +9 -2
  91. package/src/attach-helpers.mjs +105 -0
  92. package/src/attach-preflight.mjs +34 -11
  93. package/src/badge-core.mjs +1 -1
  94. package/src/cdp-client.mjs +14 -19
  95. package/src/cdp-ownership.mjs +41 -20
  96. package/src/cli-help.mjs +7 -5
  97. package/src/cli-registry.mjs +65 -46
  98. package/src/diagnostics.mjs +1 -1
  99. package/src/init.mjs +5 -3
  100. package/src/lint/index.mjs +10 -1
  101. package/src/lint/rules.mjs +371 -35
  102. package/src/observe-core.mjs +163 -34
  103. package/src/region.mjs +1 -1
  104. package/src/runtime/agent.ts +16 -12
  105. package/src/runtime/http.ts +25 -15
  106. package/src/runtime/match.ts +1 -1
  107. package/src/runtime/render.ts +4 -4
  108. package/src/transports/README.md +2 -2
  109. package/src/upgrade.mjs +33 -10
  110. package/templates/agent-starter/CLAUDE.md +8 -5
  111. package/templates/agent-starter/README.md +32 -33
  112. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  113. package/templates/agent-starter/agent/actions/index.ts +2 -2
  114. package/templates/agent-starter/agent/constants.ts +4 -3
  115. package/templates/agent-starter/agent/utils.ts +15 -18
  116. package/templates/agent-starter/claude-settings.json +1 -1
  117. package/templates/agent-starter/docs/app-profile.md +19 -14
  118. package/templates/agent-starter/foldspace.dev.json +1 -1
  119. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -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,357 @@ 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;
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.
648
+ //
649
+ // `setTestMode` is refused unless it is called with the literal `false` - and
650
+ // also when it is reached any other way (handed on, `.call`, `.bind`).
651
+ // `armAllInstances` is refused however it is used, whatever its argument:
652
+ // called (by name, under the name it was imported as, by element access),
653
+ // handed on (a timer, a listener, an object, a default value) or exported
654
+ // under another name. Even with `false` it sends a partial `setConfiguration`
655
+ // to every instance. Importing it, re-exporting it under its own name and
656
+ // types are not uses. Names are matched within the file, not by binding, so
657
+ // the rule errs on the side of refusing.
658
+ //
659
+ // One call is not refused. A code project carries its own copy of the runtime,
660
+ // and that copy's `agent/runtime/agent.ts` defines `function
661
+ // armAllInstances(testMode)`, which calls `setTestMode(testMode)`. That call -
662
+ // with the helper's own first parameter, in that function, in that file - runs
663
+ // only when something uses `armAllInstances`, and every use is refused. The
664
+ // build also checks the bundle itself (`bundleTurnsOnTestMode`), which catches
665
+ // what reading the source cannot.
666
+ export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
667
+ // path.sep, so a POSIX file whose name holds a backslash is not read as a folder.
668
+ const inRuntimeHelperFile = relative.split(path.sep).join("/") === "agent/runtime/agent.ts";
669
+
670
+ const ARM = "armAllInstances";
671
+ const armNames = new Set([ARM]);
672
+ const keyText = (key) => {
673
+ if (!key) return undefined;
674
+ if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) return key.text;
675
+ if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text;
676
+ return undefined;
677
+ };
678
+ const collectImportedNames = (node) => {
679
+ if (ts.isImportSpecifier(node) && keyText(node.propertyName ?? node.name) === ARM) armNames.add(node.name.text);
680
+ if (ts.isBindingElement(node) && ts.isIdentifier(node.name) && keyText(node.propertyName ?? node.name) === ARM) {
681
+ armNames.add(node.name.text);
682
+ }
683
+ ts.forEachChild(node, collectImportedNames);
684
+ };
685
+ collectImportedNames(sourceFile);
686
+
687
+ const memberName = (node) => {
688
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
689
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) {
690
+ return node.argumentExpression.text;
691
+ }
692
+ return "";
693
+ };
694
+ const unwrap = (node) => {
695
+ while (
696
+ node &&
697
+ (ts.isParenthesizedExpression(node) ||
698
+ ts.isNonNullExpression(node) ||
699
+ ts.isAsExpression(node) ||
700
+ ts.isSatisfiesExpression(node) ||
701
+ ts.isTypeAssertionExpression(node))
702
+ ) {
703
+ node = node.expression;
704
+ }
705
+ return node;
706
+ };
707
+ const isArm = (node) => (ts.isIdentifier(node) && armNames.has(node.text)) || memberName(node) === ARM;
708
+ const isSetTestMode = (node) =>
709
+ (ts.isIdentifier(node) && node.text === "setTestMode") || memberName(node) === "setTestMode";
710
+ const isCallee = (node) => {
711
+ let parent = node.parent;
712
+ while (parent && (ts.isParenthesizedExpression(parent) || ts.isNonNullExpression(parent) || ts.isAsExpression(parent))) {
713
+ parent = parent.parent;
714
+ }
715
+ return parent !== undefined && ts.isCallExpression(parent) && unwrap(parent.expression) === node;
716
+ };
717
+
718
+ const inImportEquals = (node) => {
719
+ let n = node;
720
+ while (n.parent && ts.isQualifiedName(n.parent)) n = n.parent;
721
+ return n.parent !== undefined && ts.isImportEqualsDeclaration(n.parent);
722
+ };
723
+ // Positions where the name is declared, imported, a key or a type - not a
724
+ // value that can be called later.
725
+ const isNamedNotUsed = (node) => {
726
+ const parent = node.parent;
727
+ if (!parent) return true;
728
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return true;
729
+ if (ts.isQualifiedName(parent)) return !inImportEquals(node);
730
+ if (ts.isExportSpecifier(parent)) return true; // the export itself is judged below, once
731
+ if (ts.isBindingElement(parent)) return parent.name === node || parent.propertyName === node;
732
+ if (
733
+ ts.isImportSpecifier(parent) ||
734
+ ts.isImportClause(parent) ||
735
+ ts.isNamespaceImport(parent) ||
736
+ ts.isTypeQueryNode(parent) ||
737
+ ts.isImportTypeNode(parent)
738
+ ) {
739
+ return true;
740
+ }
741
+ if (ts.isFunctionDeclaration(parent) && parent.name === node) {
742
+ return !parent.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
743
+ }
744
+ return (
745
+ (ts.isFunctionExpression(parent) ||
746
+ ts.isVariableDeclaration(parent) ||
747
+ ts.isParameter(parent) ||
748
+ ts.isPropertyAssignment(parent) ||
749
+ ts.isMethodDeclaration(parent) ||
750
+ ts.isMethodSignature(parent) ||
751
+ ts.isGetAccessorDeclaration(parent) ||
752
+ ts.isSetAccessorDeclaration(parent) ||
753
+ ts.isPropertyDeclaration(parent) ||
754
+ ts.isPropertySignature(parent) ||
755
+ ts.isEnumMember(parent)) &&
756
+ parent.name === node
757
+ );
758
+ };
759
+
760
+ // The helper's first parameter, when node is the runtime copy's own
761
+ // `function armAllInstances(testMode)` in agent/runtime/agent.ts.
762
+ const helperParam = (node) => {
763
+ if (!inRuntimeHelperFile || !ts.isFunctionDeclaration(node) || node.name?.text !== ARM) return undefined;
764
+ if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) return undefined;
765
+ const first = node.parameters[0];
766
+ return first && ts.isIdentifier(first.name) ? first.name.text : undefined;
767
+ };
768
+
769
+ const refuse = (node, name) => {
465
770
  findings.push(
466
771
  finding({
467
- rule: "no-unsnapshottable-color",
772
+ rule: "no-test-mode-in-bundle",
468
773
  severity: "error",
469
774
  sourceFile,
470
775
  node,
471
776
  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.",
777
+ 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.`,
778
+ 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.",
474
779
  }),
475
780
  );
476
- });
781
+ };
782
+
783
+ const walk = (node, exemptParam) => {
784
+ if (ts.isCallExpression(node) && isSetTestMode(unwrap(node.expression))) {
785
+ const [first] = node.arguments;
786
+ const off = first !== undefined && first.kind === ts.SyntaxKind.FalseKeyword;
787
+ const helperOwnCall =
788
+ exemptParam !== undefined && node.arguments.length === 1 && ts.isIdentifier(first) && first.text === exemptParam;
789
+ if (!off && !helperOwnCall) refuse(node, "setTestMode");
790
+ } else if (isSetTestMode(node) && !isCallee(node) && !(ts.isIdentifier(node) && isNamedNotUsed(node))) {
791
+ refuse(node, "setTestMode");
792
+ }
793
+ if (ts.isCallExpression(node) && isArm(unwrap(node.expression))) {
794
+ refuse(node, ARM);
795
+ } else if (isArm(node)) {
796
+ if (!isCallee(node) && !(ts.isIdentifier(node) && isNamedNotUsed(node))) refuse(node, ARM);
797
+ } else if (ts.isExportSpecifier(node)) {
798
+ const local = keyText(node.propertyName ?? node.name);
799
+ if (local !== undefined && armNames.has(local) && keyText(node.name) !== ARM) refuse(node, ARM);
800
+ }
801
+ const param = exemptParam ?? helperParam(node);
802
+ ts.forEachChild(node, (child) => walk(child, param));
803
+ };
804
+ walk(sourceFile, undefined);
805
+ }
806
+
807
+ // What the build checks in the bundle it wrote: minify keeps property names,
808
+ // so a bundle that can turn test mode on still says `setTestMode`. Calls with
809
+ // `false` (`!1` once minified) are allowed; any other mention is not - the
810
+ // net under the lint rule above, for code it cannot read (a computed name, a
811
+ // file lint does not walk).
812
+ export function bundleTurnsOnTestMode(js) {
813
+ return /setTestMode/.test(js.replace(/\??\.setTestMode(\?\.)?\(\s*(!1|false)\s*\)/g, ""));
477
814
  }
478
815
 
479
816
  export function lintActionFile(filePath, relative, findings) {
@@ -482,11 +819,10 @@ export function lintActionFile(filePath, relative, findings) {
482
819
  ruleNoStaticAgentPrompt(sourceFile, relative, findings);
483
820
  ruleNoRunTaskPrompt(sourceFile, relative, findings);
484
821
  ruleNoEmoji(sourceFile, relative, findings);
485
- ruleNoUnsnapshottableColor(sourceFile, relative, findings);
822
+ ruleCardContract(sourceFile, relative, findings);
486
823
  }
487
824
 
488
825
  export function lintViewsFile(filePath, relative, findings) {
489
826
  const sourceFile = parseFile(filePath);
490
827
  ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
491
- ruleNoUnsnapshottableColor(sourceFile, relative, findings);
492
828
  }