@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
@@ -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., analyze., admin. ...), and a
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 again.`,
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 again.`
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 and never sends a body (refused: ${asked
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. Cards must be plain hex: the panel saves them
710
- // to history as an image drawn by a library that cannot read these.
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 radius, on the panel's white surface. A dark
776
- * app keeps its accent, font and radius; its light-on-dark text and borders
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, agentPrimary } = {}) {
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}" is not a plain colour - using ${fallback}`);
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 panel, so text, muted and border are neutral greys`);
790
- const text = darkApp ? "#111827" : pick(styles.text, "#111827", "text");
791
- const muted = darkApp ? "#6B7280" : pick(styles.textMuted, "#6B7280", "textMuted");
792
- const border = darkApp ? "#E5E7EB" : pick(styles.border, "#E5E7EB", "border");
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 accent = isHex(agentPrimary)
795
- ? agentPrimary.slice(0, 7).toUpperCase()
796
- : pick(buttonBackground || styles.link, "#3248F2", "accent");
797
- const accentSource = isHex(agentPrimary) ? "the agent's own primary colour" : buttonBackground ? "the app's primary button" : styles.link ? "the app's links" : "Foldspace blue (nothing sampled)";
798
- const accentText = pick(styles.primaryButton?.color, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
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, which is",
805
- "// white, so the surface is the panel's; accent, text, border, font and radius",
806
- "// are the app's own. Re-run the command to re-sample; do not edit by eye.",
807
- "// Plain hex only: the panel saves cards to history as an image drawn by a",
808
- "// library that cannot read oklch()/lab().",
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
- ` surface: "#FFFFFF",`,
814
- ` surface2: "#F5F6FA",`,
815
- ` text: "${text}",`,
816
- ` muted: "${muted}",`,
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: { text, muted, border, accent, accentText, font, radius } };
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 Product MCP's name in the editor, as the region's playbook
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({
@@ -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 copilot in-page that is not the instance
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
- * Arm every Foldspace instance on the page for test mode and remote actions.
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
- * Calls SDK `setTestMode` and `setConfiguration({ remoteActionsSettings })`.
31
- * Local `foldspace attach` already injects test-mode arming without putting it
32
- * in `dist/index.js`. Prefer that. Importing this helper from an action can
33
- * ship those calls into the production CDN bundle.
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
- * Do not call from `execute`. Partial `setConfiguration` has collapsed the
36
- * widget to 0×0 in a real build.
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 = true): {
45
+ export function armAllInstances(testMode = false): {
42
46
  armed: string[];
43
47
  failed: string[];
44
48
  } {
@@ -96,16 +96,17 @@ export interface ApiSuccess<T> {
96
96
  /**
97
97
  * Failed request. Errors are **returned**, never thrown.
98
98
  *
99
- * Show `error` to a person. Log `detail` to the console only — never put
100
- * `detail` in an action return value; Foldspace may quote that to the user.
99
+ * Show `error` to a person. Log `detail` to the console only - never put
100
+ * `detail` in an action return value; the agent may quote it to the end user.
101
101
  *
102
102
  * `unreachable` is true when the browser never got a response (DNS, CORS,
103
103
  * offline, connection refused). Timeouts set `unreachable: false` and
104
104
  * `error: "Request timed out."`. Empty `API_BASE` / `AUTH_SOURCE` also
105
- * return `{ ok: false }` with status 0 — they do not throw.
105
+ * return `{ ok: false }` with status 0 - they do not throw: `reason: "config"`,
106
+ * `error: "This is not set up yet."`, and which setting is missing in `detail`.
106
107
  *
107
108
  * Branch on `reason`, not on `status`: a search that finds nothing, an expired
108
- * session and a throttled call are three different widget states, and
109
+ * session and a throttled call are three different card states, and
109
110
  * `renderFailure` picks the right one for you.
110
111
  */
111
112
  export interface ApiFailure {
@@ -121,7 +122,7 @@ export interface ApiFailure {
121
122
  status: number;
122
123
  /** Safe, user-facing sentence. Pass this to `renderError`. */
123
124
  error: string;
124
- /** Truncated API body for the console. Never show this in the widget. */
125
+ /** For the console only: the API's body (truncated), or which setting is missing. Never show this in the card. */
125
126
  detail?: string;
126
127
  /**
127
128
  * True when the network itself failed (not HTTP 4xx/5xx, not abort).
@@ -131,7 +132,7 @@ export interface ApiFailure {
131
132
  }
132
133
 
133
134
  /**
134
- * Why a call failed, in terms a widget can act on.
135
+ * Why a call failed, in terms a card can act on.
135
136
  *
136
137
  * - `signed_out` — no token on the page, or the API answered 401. The session
137
138
  * observed at build time is not the session at run time; expect this.
@@ -155,6 +156,11 @@ export type FailureReason =
155
156
  /** Discriminated result of a customer-API call. Branch on `ok`. */
156
157
  export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
157
158
 
159
+ // What reaches the agent and the card when the build is not configured yet:
160
+ // a state, never a developer instruction. The developer hint is in `detail`,
161
+ // for the console only: nothing shows it unless the handler logs it.
162
+ const NOT_SET_UP = "This is not set up yet.";
163
+
158
164
  function missingConfigResult(): ApiResult<never> | null {
159
165
  const config = getConfig();
160
166
  if (!config.apiBase) {
@@ -162,7 +168,8 @@ function missingConfigResult(): ApiResult<never> | null {
162
168
  ok: false,
163
169
  status: 0,
164
170
  reason: "config",
165
- error: "API_BASE is not set — capture the app's XHR first.",
171
+ error: NOT_SET_UP,
172
+ detail: "API_BASE is not set - capture the app's XHR first.",
166
173
  };
167
174
  }
168
175
  if (!config.authSource) {
@@ -170,7 +177,8 @@ function missingConfigResult(): ApiResult<never> | null {
170
177
  ok: false,
171
178
  status: 0,
172
179
  reason: "config",
173
- error: "AUTH_SOURCE is not set — capture the app's session first.",
180
+ error: NOT_SET_UP,
181
+ detail: "AUTH_SOURCE is not set - capture the app's session first.",
174
182
  };
175
183
  }
176
184
  return null;
@@ -196,7 +204,7 @@ function retryAfterMs(res: Response): number | undefined {
196
204
  }
197
205
 
198
206
  /**
199
- * One mapping from HTTP status to a failure a widget can act on.
207
+ * One mapping from HTTP status to a failure a card can act on.
200
208
  *
201
209
  * Exported for a tenant's own `apiFetch` — an app that authenticates with a
202
210
  * custom header or cookies replaces the transport, and should not also have to
@@ -206,7 +214,7 @@ export function httpFailure(res: Response, fallback: string, detail?: string): A
206
214
  const base = { ok: false as const, status: res.status, detail };
207
215
  switch (res.status) {
208
216
  case 401:
209
- return { ...base, reason: "signed_out", error: "Your session expired. Sign in again." };
217
+ return { ...base, reason: "signed_out", error: "Your session has expired." };
210
218
  case 403:
211
219
  return { ...base, reason: "forbidden", error: "You do not have access to that." };
212
220
  case 404:
@@ -216,7 +224,7 @@ export function httpFailure(res: Response, fallback: string, detail?: string): A
216
224
  ...base,
217
225
  reason: "rate_limited",
218
226
  retryAfterMs: retryAfterMs(res),
219
- error: "Too many requests. Try again in a moment.",
227
+ error: "Too many requests right now.",
220
228
  };
221
229
  default:
222
230
  return { ...base, reason: "server", error: fallback };
@@ -245,9 +253,10 @@ const SIGNED_OUT: ApiFailure = {
245
253
  /**
246
254
  * Authenticated JSON request against `apiBase` with `Authorization: Bearer`.
247
255
  *
248
- * Matches the common pattern (two of three early builds). If the live page
249
- * uses cookies, custom headers, or no Bearer, **stop re-exporting this** from
250
- * `agent/utils.ts` and write a local `apiFetch` instead.
256
+ * For an app whose own requests carry `Authorization: Bearer`. If the live
257
+ * page sends something else (a custom header, or cookies with no token),
258
+ * follow `recipes/swap-the-login-method/README.md`: it names the file to copy
259
+ * over `agent/utils.ts` for each case.
251
260
  *
252
261
  * Empty `API_BASE` / `AUTH_SOURCE` return `{ ok: false }` so a guessed host
253
262
  * cannot look like success. Errors never throw and never include the token.
@@ -401,7 +410,8 @@ export async function publicFetch<T = unknown>(
401
410
  ok: false,
402
411
  status: 0,
403
412
  reason: "config",
404
- error: "No public origin is configured for this app.",
413
+ error: NOT_SET_UP,
414
+ detail: "No public origin is set - pass one to publicFetch, or set publicOrigin in configure().",
405
415
  };
406
416
  }
407
417
  const controller = new AbortController();
@@ -41,7 +41,7 @@ const PLAUSIBLE = 0.45;
41
41
  * SDK method degrades the result rather than breaking the action.
42
42
  *
43
43
  * @param items - Records already loaded (e.g. from {@link apiFetch})
44
- * @param query - What the user typed
44
+ * @param query - What the end user typed
45
45
  * @param opts - Fields, identity, optional limit
46
46
  * @returns Up to `limit` items, substring matches first
47
47
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Unstyled chatterblock states. Tenant brand belongs in `agent/views/`.
2
+ * Unstyled card states. Tenant brand belongs in `agent/views/`.
3
3
  *
4
4
  * An action's `render` output cannot be typechecked. Look at a frame. Never
5
5
  * use `innerHTML` with API data.
@@ -38,7 +38,7 @@ function replace(host: ViewHost, node: HTMLElement): void {
38
38
  }
39
39
 
40
40
  /**
41
- * In-progress chatterblock. Unstyled scaffolding — replace with branded UI
41
+ * In-progress card. Unstyled scaffolding - replace with branded UI
42
42
  * when the card is the experience.
43
43
  *
44
44
  * @param host - Foldspace `render` host
@@ -60,8 +60,8 @@ export function renderEmpty(host: ViewHost, message: string): void {
60
60
  }
61
61
 
62
62
  /**
63
- * Recoverable error: the user can retry or narrow. Pass `ApiResult.error`,
64
- * never `detail` — `detail` is the API's own words and is for the console.
63
+ * Recoverable error: the end user can retry or narrow. Pass `ApiResult.error`,
64
+ * never `detail` - it is for the console.
65
65
  *
66
66
  * @param host - Foldspace `render` host
67
67
  * @param message - Safe, user-facing sentence
@@ -6,7 +6,7 @@ The harness does the same three jobs regardless of where the browser is:
6
6
 
7
7
  Track A (`local`) does them over CDP against a browser on this machine.
8
8
  Track B (`extension`) will do them over a websocket to a Web Store extension in
9
- the user's own Chrome.
9
+ the human's own Chrome.
10
10
 
11
11
  `agent/`, `fixtures/` and the build are identical in both — only this layer
12
12
  differs. Keep it that way: "export to repo" from a hosted build is a copy only
@@ -21,4 +21,4 @@ while the two tracks share an identical `agent/` directory.
21
21
  | `navigate` | `Page.navigate` | `tabs.update` |
22
22
 
23
23
  `local` is implemented today as `bin/inject.mjs` + `bin/attach.mjs`.
24
- `extension` is not built; see `architecture-hosted.md`.
24
+ `extension` is not built.
package/src/upgrade.mjs CHANGED
@@ -9,7 +9,7 @@ export const HARNESS_PACKAGE = "@foldspace_npm/harness";
9
9
  export const INSTRUCTION_IMPORT = `@node_modules/${HARNESS_PACKAGE}/CLAUDE.md`;
10
10
  export const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
11
11
  export const NPM_VIEW_TIMEOUT_MS = 2000;
12
- export const UPGRADE_NEXT = "Ask the user, then run foldspace upgrade --yes";
12
+ export const UPGRADE_NEXT = "Ask the human, then run npx foldspace upgrade --yes";
13
13
 
14
14
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
15
  const instructionTemplatePath = path.join(
@@ -267,11 +267,11 @@ function nextAction(report) {
267
267
  }
268
268
  if (report.status === "outdated") {
269
269
  return report.instructionsImport === false
270
- ? `${UPGRADE_NEXT}. If CLAUDE.md is still a snapshot, also pass --refresh-instructions`
270
+ ? "CLAUDE.md does not import the package instructions. Ask the human, then run npx foldspace upgrade --yes --refresh-instructions (the old CLAUDE.md is kept as CLAUDE.md.bak)."
271
271
  : UPGRADE_NEXT;
272
272
  }
273
273
  if (report.status === "upgraded") {
274
- return "Run npm run build. Deploy only if product users should receive the rebuilt runtime.";
274
+ return "Run `npm run build`. If attach is running, run `npx foldspace attach --stop`, then `npx foldspace attach --daemon` with the flags it ran with, to load the rebuild.";
275
275
  }
276
276
  return null;
277
277
  }
@@ -311,8 +311,14 @@ export function checkForUpdate(projectDir, options = {}) {
311
311
  // new answer down. `foldspace upgrade` is always fresh: someone asking to
312
312
  // upgrade minutes after a release was told the previous version was the
313
313
  // latest, for up to 12 hours (seen 2026-09-28).
314
+ // `preferFresh` (help --json, which the manual reads first) asks the
315
+ // registry too, but falls back to the remembered answer when the registry
316
+ // cannot be reached, so an offline session still hears something.
317
+ const preferFresh = options.preferFresh === true;
314
318
  const cached =
315
- options.cache === false || options.fresh === true ? null : readCache(projectDir, now);
319
+ options.cache === false || options.fresh === true || preferFresh
320
+ ? null
321
+ : readCache(projectDir, now);
316
322
  const cachedDeprecation =
317
323
  cached?.deprecation?.version === running ? cached.deprecation : null;
318
324
  if (cached?.latest && cachedDeprecation) {
@@ -329,12 +335,25 @@ export function checkForUpdate(projectDir, options = {}) {
329
335
  ? options.fetchDeprecation(running)
330
336
  : fetchDeprecationFromNpm(running, options);
331
337
  deprecated = message || null;
332
- if (latest && options.cache !== false) {
333
- const entry = { latest };
338
+ // Each of the two answers falls back on its own: the version can arrive
339
+ // while the withdrawn-version check times out, and that warning is the
340
+ // one step 1 must not lose.
341
+ const fetchedLatest = latest;
342
+ const remembered = preferFresh && options.cache !== false ? readCache(projectDir, now) : null;
343
+ const rememberedDeprecation =
344
+ remembered?.deprecation?.version === running ? remembered.deprecation : null;
345
+ if (!fetchedLatest && remembered?.latest) latest = remembered.latest;
346
+ if (typeof message !== "string" && rememberedDeprecation) {
347
+ deprecated = rememberedDeprecation.message || null;
348
+ }
349
+ if (fetchedLatest && options.cache !== false) {
350
+ const entry = { latest: fetchedLatest };
334
351
  // Only cache an answer the registry actually gave, so an offline
335
352
  // run is asked again next time instead of remembered as "fine".
336
353
  if (typeof message === "string") {
337
354
  entry.deprecation = { version: running, message };
355
+ } else if (rememberedDeprecation) {
356
+ entry.deprecation = rememberedDeprecation;
338
357
  }
339
358
  writeCache(projectDir, entry, now);
340
359
  }
@@ -367,7 +386,7 @@ export function checkForUpdate(projectDir, options = {}) {
367
386
  /**
368
387
  * What `build` and `attach` do with an update report before they run:
369
388
  * `block: true` stops the command, `message` (if any) is printed to stderr.
370
- * Never upgrades - that stays the user's decision (CLAUDE.md, step 1).
389
+ * Never upgrades - that stays the human's decision (CLAUDE.md, step 1).
371
390
  * The escape hatch is FOLDSPACE_SKIP_UPDATE_CHECK=1: the report comes back
372
391
  * check_skipped, so nothing here blocks.
373
392
  */
@@ -379,7 +398,7 @@ export function gateForCommand(report) {
379
398
  block: true,
380
399
  message:
381
400
  `${HARNESS_PACKAGE} ${report.installed || report.pinned} was withdrawn: ${report.deprecated}. ` +
382
- `${UPGRADE_NEXT}. If the user declines, rerun with FOLDSPACE_SKIP_UPDATE_CHECK=1 to build on this version anyway.`,
401
+ `${UPGRADE_NEXT}. If the human declines, rerun with FOLDSPACE_SKIP_UPDATE_CHECK=1 to run this version anyway.`,
383
402
  };
384
403
  }
385
404
  if (report.outdated) {
@@ -487,10 +506,14 @@ export async function runUpgrade(argv, options = {}) {
487
506
  log(
488
507
  `${HARNESS_PACKAGE} ${report.pinned || report.installed} -> ${report.latest} is available`,
489
508
  );
490
- const answer = await ask("Upgrade the exact pin in this project? [Y/n] ", options);
509
+ const answer = await ask("Upgrade the version this project uses? [Y/n] ", options);
491
510
  shouldInstall = isAffirmative(answer);
492
511
  if (!shouldInstall) {
493
- return { ...report, status: "declined", next: UPGRADE_NEXT };
512
+ // The answer was no: asking again is not the next step.
513
+ const next = report.broken
514
+ ? "The human declined: do not ask again this session. This version is withdrawn, so build and attach refuse it; run them with FOLDSPACE_SKIP_UPDATE_CHECK=1."
515
+ : "The human declined: carry on with this version, and do not ask again this session.";
516
+ return { ...report, status: "declined", next };
494
517
  }
495
518
  if (!report.instructionsImport && !shouldRefresh) {
496
519
  const refreshAnswer = await ask(
@@ -2,15 +2,18 @@
2
2
 
3
3
  @node_modules/@foldspace_npm/harness/CLAUDE.md
4
4
 
5
- If that import did not resolve, read `node_modules/@foldspace_npm/harness/CLAUDE.md`
6
- before building. What follows is only what is true of **this** product.
5
+ That import is the manual for this build. If it did not resolve, read
6
+ `node_modules/@foldspace_npm/harness/CLAUDE.md` before anything else; if that
7
+ file is missing too, run `npm install --ignore-scripts` first. What follows is
8
+ only what is true of **this** product.
7
9
 
8
10
  ## This product
9
11
 
10
12
  | | |
11
13
  |---|---|
12
- | Agent | `{{AGENT_API_NAME}}` |
13
- | Product | `{{PRODUCT_ID}}` |
14
+ | Agent Key | `{{AGENT_API_NAME}}` |
15
+ | Product ID | `{{PRODUCT_ID}}` |
14
16
  | App | `{{APP_DOMAIN}}` |
15
17
 
16
- Observed API, auth, and account facts for this app live in `docs/app-profile.md`.
18
+ What is known about this app, and how each fact was established, is in
19
+ `docs/app-profile.md`.