@amityco/social-plus-vise 1.2.0 → 1.4.0

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 (47) hide show
  1. package/CHANGELOG.md +75 -2
  2. package/README.md +23 -10
  3. package/dist/capabilities.js +35 -4
  4. package/dist/entryState.js +71 -0
  5. package/dist/experience.js +70 -0
  6. package/dist/explore.js +51 -0
  7. package/dist/flow.js +382 -0
  8. package/dist/humanFormat.js +251 -0
  9. package/dist/intake.js +117 -0
  10. package/dist/intelligence/placement.js +2 -1
  11. package/dist/outcomes.js +141 -42
  12. package/dist/productExpectations.js +15 -0
  13. package/dist/requestReadiness.js +99 -0
  14. package/dist/server.js +675 -56
  15. package/dist/sidecar.js +5 -0
  16. package/dist/solutionPath.js +2 -2
  17. package/dist/tools/compliance.js +1014 -133
  18. package/dist/tools/creative.js +14 -12
  19. package/dist/tools/debug.js +83 -26
  20. package/dist/tools/design.js +344 -14
  21. package/dist/tools/experienceCompiler.js +1 -1
  22. package/dist/tools/experienceSensors.js +1 -1
  23. package/dist/tools/harness.js +13 -0
  24. package/dist/tools/integration.js +263 -90
  25. package/dist/tools/learning.js +1 -3
  26. package/dist/tools/project.js +987 -72
  27. package/dist/tools/sdkFacts.js +8 -1
  28. package/dist/tools/smoke.js +134 -0
  29. package/dist/tools/uxHarness.js +54 -6
  30. package/dist/uikitCustomization.js +22 -6
  31. package/dist/version.js +7 -3
  32. package/package.json +1 -1
  33. package/packages/intelligence/catalog/catalog.schema.json +1 -1
  34. package/packages/intelligence/catalog/experience-objects.json +2 -2
  35. package/packages/intelligence/catalog/variants.json +24 -0
  36. package/rules/event.yaml +3 -0
  37. package/rules/feed.yaml +251 -0
  38. package/rules/invitation.yaml +4 -0
  39. package/rules/live-data.yaml +110 -0
  40. package/rules/notification-tray.yaml +4 -0
  41. package/rules/poll.yaml +5 -0
  42. package/rules/sdk-lifecycle.yaml +559 -0
  43. package/rules/search.yaml +5 -0
  44. package/rules/security.yaml +12 -0
  45. package/rules/story.yaml +5 -0
  46. package/rules/user-blocking.yaml +5 -0
  47. package/skills/social-plus-vise/SKILL.md +163 -15
@@ -174,9 +174,16 @@ export async function getSdkFacts(options) {
174
174
  notes: [
175
175
  "Symbol facts are existence-only: Vise confirms public symbols in the normalized SDK surface, but semantic correctness and idiomatic usage still require rules, docs, and sensors.",
176
176
  modelFacts.facts
177
- ? `Field-level model facts are extraction-grounded (${modelFacts.facts.fieldsGrounding}) by ${modelFacts.facts.extraction.extractor}; names-only grounding means the source proves field names but no types.`
177
+ ? `Field-level model facts are extraction-grounded (${modelFacts.facts.fieldsGrounding}) by ${modelFacts.facts.extraction.extractor}.${modelFacts.facts.fieldsGrounding === "names-only"
178
+ ? " names-only grounding means the source proves field names but no types."
179
+ : ""}`
178
180
  : "No field-level model facts for this platform snapshot: model claims stay symbol-only (absence over fabrication).",
179
181
  "Remote SP_SDK_SURFACE_URL resolution is recorded in the manifest for a future online snapshot source; this MVP uses local override/env/bundled directories only.",
182
+ ...(platform !== "typescript"
183
+ ? [
184
+ `Capability anchors are authored TypeScript-first at this MVP, so capabilities is empty for ${platform}. This means capability mapping is not yet available on ${platform} — it does NOT mean the SDK lacks the feature. The symbol and model facts above are extracted from the ${platform} surface and remain authoritative.`,
185
+ ]
186
+ : []),
180
187
  ],
181
188
  };
182
189
  if (options.includeSymbols) {
@@ -0,0 +1,134 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
4
+ function escapeRegExp(s) {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ }
7
+ function loadedCount(state) {
8
+ const match = state.match(/^loaded\b[\s\S]*?\bcount\s*=\s*(\d+)/i);
9
+ return match ? Number(match[1]) : null;
10
+ }
11
+ export function assessSmokeLog(logText, surfaces) {
12
+ const results = surfaces.map((surface) => {
13
+ const re = new RegExp(`VISE_SMOKE\\s+surface=${escapeRegExp(surface.id)}\\s+state=(.+)`, "g");
14
+ let match;
15
+ let last;
16
+ while ((match = re.exec(logText)) !== null)
17
+ last = match[1].trim();
18
+ if (last === undefined) {
19
+ return {
20
+ surface: surface.id,
21
+ state: "(no signal)",
22
+ verdict: "fail",
23
+ reason: "surface never emitted a VISE_SMOKE state — it did not mount, or the log marker is not wired",
24
+ };
25
+ }
26
+ const isLoaded = /^loaded\b/.test(last);
27
+ const isResolved = isLoaded || /^empty\b/.test(last);
28
+ const expect = surface.expect ?? "resolved";
29
+ const count = loadedCount(last);
30
+ const ok = expect === "populated" ? isLoaded && count !== null && count > 0 : isResolved;
31
+ if (ok)
32
+ return { surface: surface.id, state: last, verdict: "pass" };
33
+ let reason;
34
+ if (/^error/.test(last)) {
35
+ reason = "query errored before the session was ready (session-establish race) — drive it off a reactive readiness signal and retry the transient 'logging in' error";
36
+ }
37
+ else if (/^loading/.test(last)) {
38
+ reason = "surface never left loading — the query was created before the session was ready and did not recover";
39
+ }
40
+ else if (expect === "populated" && isLoaded && count === null) {
41
+ reason = "expected populated proof but the loaded state did not include count=N — emit the visible SDK item count";
42
+ }
43
+ else if (expect === "populated" && isLoaded) {
44
+ reason = `expected populated proof with count > 0 but loaded count=${count}`;
45
+ }
46
+ else if (expect === "populated") {
47
+ reason = "expected populated (loaded) but the surface resolved empty — verify data is present";
48
+ }
49
+ else {
50
+ reason = `surface did not reach a resolved state (state=${last})`;
51
+ }
52
+ return { surface: surface.id, state: last, verdict: "fail", reason };
53
+ });
54
+ return { results, passed: results.length > 0 && results.every((r) => r.verdict === "pass") };
55
+ }
56
+ async function loadSmokeConfig(repoRoot) {
57
+ const file = path.join(repoRoot, "sp-vise", "smoke.json");
58
+ try {
59
+ const raw = await fs.readFile(file, "utf8");
60
+ const parsed = JSON.parse(raw);
61
+ if (!Array.isArray(parsed.surfaces))
62
+ return null;
63
+ return parsed;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function smokeCaptureHelp() {
70
+ return [
71
+ "Create `sp-vise/smoke.json` with the mounted collection surfaces, for example `{ \"platform\": \"android\", \"surfaces\": [{ \"id\": \"feed\", \"expect\": \"populated\" }] }`.",
72
+ "Emit `VISE_SMOKE surface=<id> state=loading|empty|loaded count=N|error:<message>` from the real SDK query lifecycle; `expect: \"populated\"` requires loaded count=N with N > 0.",
73
+ "Android: clear logcat before launch, cold-launch, wait for resolution, then `adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log`.",
74
+ "iOS: after `simctl launch`, run `xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log`.",
75
+ "Then run `vise smoke . --log sp-vise/evidence/runtime-smoke.log` and rerun `vise check --ci`.",
76
+ ].join(" ");
77
+ }
78
+ export const smokeTool = {
79
+ name: "smoke",
80
+ description: "Assess a captured runtime mount-smoke log (VISE_SMOKE markers) into a pass/fail verdict and record runtime-smoke evidence. Catches the session-establish race that static check cannot.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ repoPath: { type: "string", description: "Absolute or relative path to the customer repository root." },
85
+ logPath: {
86
+ type: "string",
87
+ description: "Path to a captured runtime log containing `VISE_SMOKE surface=<id> state=<…>` markers (produced by the platform capture harness, e.g. bench/ios-runtime-smoke.sh, cold-launching into each collection surface).",
88
+ },
89
+ surface: { type: "string", description: "Optional: assess only this surface id from smoke.json." },
90
+ },
91
+ required: ["repoPath", "logPath"],
92
+ additionalProperties: false,
93
+ },
94
+ async call(input) {
95
+ const args = objectInput(input);
96
+ const repoRoot = path.resolve(stringField(args, "repoPath"));
97
+ const logPath = path.resolve(repoRoot, stringField(args, "logPath"));
98
+ const only = optionalStringField(args, "surface");
99
+ const config = await loadSmokeConfig(repoRoot);
100
+ if (!config) {
101
+ return textResult({
102
+ status: "no-config",
103
+ note: `No sp-vise/smoke.json found. ${smokeCaptureHelp()}`,
104
+ });
105
+ }
106
+ let logText;
107
+ try {
108
+ logText = await fs.readFile(logPath, "utf8");
109
+ }
110
+ catch {
111
+ return textResult({ status: "no-log", note: `Could not read captured log at ${logPath}. ${smokeCaptureHelp()}` });
112
+ }
113
+ const surfaces = only ? config.surfaces.filter((s) => s.id === only) : config.surfaces;
114
+ if (surfaces.length === 0) {
115
+ return textResult({ status: "no-surfaces", note: only ? `Surface '${only}' is not declared in smoke.json.` : "smoke.json declares no surfaces." });
116
+ }
117
+ const assessment = assessSmokeLog(logText, surfaces);
118
+ try {
119
+ const evidenceDir = path.join(repoRoot, "sp-vise", "evidence");
120
+ await fs.mkdir(evidenceDir, { recursive: true });
121
+ await fs.writeFile(path.join(evidenceDir, "runtime-smoke.json"), JSON.stringify({ passed: assessment.passed, platform: config.platform, results: assessment.results }, null, 2));
122
+ }
123
+ catch {
124
+ }
125
+ return textResult({
126
+ status: assessment.passed ? "passed" : "failed",
127
+ platform: config.platform,
128
+ results: assessment.results,
129
+ ...(assessment.passed
130
+ ? {}
131
+ : { note: "Runtime mount-smoke FAILED — at least one collection surface did not reach a resolved state on cold-launch (session-establish race). This is invisible to `vise check`." }),
132
+ });
133
+ },
134
+ };
@@ -111,6 +111,7 @@ export function uxHarnessPlanContext(harness, outcome) {
111
111
  antiPatterns,
112
112
  implementationGuidance: [
113
113
  `Preserve the selected variant's UX pattern(s): ${harness.uxPatterns.map((pattern) => pattern.name).join(", ") || "(none)"}.`,
114
+ "Keep Vise, SDK, intake, scope, dogfood, and auth/debug vocabulary in implementation notes, evidence, or dev-only diagnostics; visible app copy should use the host product's language.",
114
115
  ...expectations.slice(0, 6).map((expectation) => `${expectation.title}: ${expectation.guidance}`),
115
116
  ...(antiPatterns.length > 0
116
117
  ? [`Avoid these UX anti-patterns: ${antiPatterns.slice(0, 4).map((item) => item.warning).join("; ")}.`]
@@ -142,6 +143,7 @@ export async function assessUxHarness(effectiveRoot, harness, outcome) {
142
143
  };
143
144
  }
144
145
  const corpus = await collectSourceCorpus(effectiveRoot);
146
+ const copyRisks = productionCopyRisksFor(corpus);
145
147
  const assessed = expectations.map((expectation) => {
146
148
  const evidence = evidenceFor(expectation, corpus);
147
149
  return {
@@ -158,7 +160,10 @@ export async function assessUxHarness(effectiveRoot, harness, outcome) {
158
160
  acc[item.status] = (acc[item.status] ?? 0) + 1;
159
161
  return acc;
160
162
  }, {});
161
- const status = assessed.some((item) => item.status === "needs-review" && item.priority === "high")
163
+ if (copyRisks.length > 0) {
164
+ summary["copy-risk"] = copyRisks.length;
165
+ }
166
+ const status = copyRisks.length > 0 || assessed.some((item) => item.status === "needs-review" && item.priority === "high")
162
167
  ? "needs-review"
163
168
  : "evidence-found";
164
169
  return {
@@ -166,9 +171,10 @@ export async function assessUxHarness(effectiveRoot, harness, outcome) {
166
171
  harnessPath: "sp-vise/ux-harness.json",
167
172
  advisoryOnly: advisoryOnlyText(),
168
173
  assessedExpectations: assessed,
174
+ ...(copyRisks.length > 0 ? { copyRisks } : {}),
169
175
  summary,
170
176
  nextStep: status === "needs-review"
171
- ? "Review UX Harness needs-review items with the user or host agent. They are advisory and do not affect `vise check` exit codes."
177
+ ? "Review UX Harness needs-review items and production-copy risks with the user or host agent. They are advisory and do not affect `vise check` exit codes."
172
178
  : "UX Harness advisory evidence was found. Keep reviewing visually before claiming production UX quality.",
173
179
  };
174
180
  }
@@ -224,13 +230,13 @@ async function readCreativeSelectionSidecar(repoRoot) {
224
230
  raw = await readFile(filePath, "utf8");
225
231
  }
226
232
  catch {
227
- throw new Error(`Unable to read ${rel}. Run \`vise creative accept . --variant <id>\` first.`);
233
+ throw new Error(`Unable to read ${rel}. Run \`vise creative accept . --variant <id> --rationale "<why>" --confidence high\` first.`);
228
234
  }
229
235
  try {
230
236
  return JSON.parse(raw);
231
237
  }
232
238
  catch {
233
- throw new Error(`${rel} is present but could not be parsed (corrupt JSON). Re-run \`vise creative accept . --variant <id>\` to regenerate it.`);
239
+ throw new Error(`${rel} is present but could not be parsed (corrupt JSON). Re-run \`vise creative accept . --variant <id> --rationale "<why>" --confidence high\` to regenerate it.`);
234
240
  }
235
241
  }
236
242
  async function writeUxHarness(repoRoot, harness) {
@@ -331,6 +337,7 @@ function surfaceIdForOutcome(outcome) {
331
337
  async function collectSourceCorpus(root) {
332
338
  const filesByHint = new Map();
333
339
  const chunks = [];
340
+ const corpusFiles = [];
334
341
  const files = await sourceFiles(root);
335
342
  for (const file of files) {
336
343
  const text = await readFile(file, "utf8").catch(() => "");
@@ -339,6 +346,7 @@ async function collectSourceCorpus(root) {
339
346
  }
340
347
  const lower = text.toLowerCase();
341
348
  chunks.push(lower);
349
+ corpusFiles.push({ path: repoRelativePath(root, file), lower });
342
350
  for (const hint of UX_EVIDENCE_HINTS) {
343
351
  if (!lower.includes(hint)) {
344
352
  continue;
@@ -349,7 +357,7 @@ async function collectSourceCorpus(root) {
349
357
  filesByHint.set(hint, existing);
350
358
  }
351
359
  }
352
- return { text: chunks.join("\n"), filesByHint };
360
+ return { text: chunks.join("\n"), filesByHint, files: corpusFiles };
353
361
  }
354
362
  async function sourceFiles(root) {
355
363
  const result = [];
@@ -395,6 +403,32 @@ function evidenceFor(expectation, corpus) {
395
403
  }
396
404
  return { hints, files: [...files].sort().slice(0, 8) };
397
405
  }
406
+ function productionCopyRisksFor(corpus) {
407
+ const filesByTerm = new Map();
408
+ for (const file of corpus.files) {
409
+ for (const term of PRODUCTION_COPY_RISK_TERMS) {
410
+ if (!file.lower.includes(term)) {
411
+ continue;
412
+ }
413
+ const existing = filesByTerm.get(term) ?? new Set();
414
+ existing.add(file.path);
415
+ filesByTerm.set(term, existing);
416
+ }
417
+ }
418
+ const riskyTerms = [...filesByTerm.keys()].sort();
419
+ if (riskyTerms.length === 0) {
420
+ return [];
421
+ }
422
+ return [
423
+ {
424
+ status: "needs-review",
425
+ title: "Visible app copy may expose Vise planning or debug vocabulary",
426
+ terms: riskyTerms,
427
+ files: [...new Set(riskyTerms.flatMap((term) => [...(filesByTerm.get(term) ?? [])]))].sort().slice(0, 12),
428
+ guidance: "Rewrite customer-visible labels and descriptions into the host product language, and move SDK/session/scope/dogfood details to comments, handoff notes, or dev-only diagnostics.",
429
+ },
430
+ ];
431
+ }
398
432
  function projectSummary(inspection) {
399
433
  return {
400
434
  repoRoot: inspection.repoRoot,
@@ -475,11 +509,12 @@ const UX_PATTERN_PRESETS = {
475
509
  expectations: [
476
510
  expectation("embedded-feed-context", "Feed appears near the host context it supports", "high", ["feed"], "Place the feed close to the product, content, home, or detail context it enriches.", ["feed", "postrepository", "targetid", "targettype", "communityid", "userid"]),
477
511
  expectation("embedded-feed-composer-scope", "Feed composer or read-only scope is explicit", "high", ["feed"], "If creation is in scope, expose a composer with clear target scope; otherwise state that the feed is read-only.", ["composer", "createpost", "readonly", "read only", "postrepository.createpost"]),
512
+ expectation("embedded-feed-post-body", "Post cards render the body content", "high", ["feed"], "Render each post's normal body/description/caption when present instead of making postId or counts the primary card content. A title field is optional; do not require metadata/custom title payloads just to satisfy rendering.", ["post.data.text", "gettext", "description", "caption", "postbody", "postcontent"]),
478
513
  expectation("embedded-feed-rich-post-rendering", "Rich post types render as rich content when in scope", "medium", ["feed"], "If the feed includes image, video, file, poll, or shared parent-child posts, render them as rich content with media/poll/quoted UI and loading/fallback states instead of flattening every post to plain text. The deterministic feed.rich-post-rendering sensor (parent-child-rendered / post-datatype-handled) provides platform evidence.", ["getimageinfo", "getfileinfo", "fileinfo", "imageinfo", "getpoll", "poll", "parent", "children", "mediarepository", "thumbnail"]),
479
514
  expectation("embedded-feed-rich-post-create", "Composer surfaces rich post create affordances when in scope", "medium", ["feed"], "If image, file, or poll creation is in scope, surface those affordances in the composer rather than only a plain text input. The deterministic feed.rich-post-composer-scope sensor provides platform evidence.", ["imagepicker", "pickimage", "mediapicker", "createpoll", "create poll", "addimage", "imageupload", "attachimage"]),
480
515
  ],
481
516
  tradeoffs: ["Fits existing journeys without adding navigation", "Can be overlooked if buried too far below primary content"],
482
- antiPatterns: ["Feed disconnected from the host object", "Composer creates into an unclear target", "Rich posts (image/video/poll/parent-child) flattened to plain text", "Composer limited to text when richer creation is in scope"],
517
+ antiPatterns: ["Feed disconnected from the host object", "Composer creates into an unclear target", "Vise/intake/debug labels exposed as visible product copy", "Rich posts (image/video/poll/parent-child) flattened to plain text", "Composer limited to text when richer creation is in scope"],
483
518
  },
484
519
  "contextual-comment-tray": {
485
520
  expectations: [
@@ -547,3 +582,16 @@ const UX_PATTERN_PRESETS = {
547
582
  },
548
583
  };
549
584
  const UX_EVIDENCE_HINTS = Array.from(new Set(Object.values(UX_PATTERN_PRESETS).flatMap((preset) => preset.expectations.flatMap((expectation) => expectation.evidenceHints)))).sort();
585
+ const PRODUCTION_COPY_RISK_TERMS = [
586
+ "composer scope",
587
+ "current signed-in social plus",
588
+ "dev/testing",
589
+ "dogfood",
590
+ "engagement scope",
591
+ "feed target resolved",
592
+ "post type scope",
593
+ "resolved against",
594
+ "sdk path",
595
+ "social plus user feed",
596
+ "target: your current social plus",
597
+ ].sort();
@@ -4,7 +4,7 @@ const SIGNALS = [
4
4
  level: "dynamic-ui",
5
5
  label: "Minimal, server-driven brand or theme updates",
6
6
  strength: "strong",
7
- pattern: /\b(dynamic ui|remote config|network config|syncNetworkConfig|without (?:an? )?(?:app )?(?:release|update|rebuild)|real[-\s]?time theme|brand colors?|theme tokens?|palette|console[-\s]?driven)\b/i,
7
+ pattern: /\b(dynamic ui|remote config|network config|syncNetworkConfig|without (?:an? )?(?:app )?(?:release|update|rebuild)|(?:with no|no)\s+(?:an? )?(?:app )?(?:release|rebuild|re-?deploy(?:ment)?|deploy)|real[-\s]?time theme|brand colors?|theme tokens?|palette|console[-\s]?driven)\b/i,
8
8
  },
9
9
  {
10
10
  id: "component-styling",
@@ -39,7 +39,7 @@ const SIGNALS = [
39
39
  level: "sdk-custom-ui",
40
40
  label: "Custom UI beyond UIKit's customization model",
41
41
  strength: "strong",
42
- pattern: /\b(totally different ui|brand[-\s]?new ui|bespoke ui|unique ui|complete design freedom|build from scratch|hand[-\s]?rolled)\b/i,
42
+ pattern: /\b(totally different ui|brand[-\s]?new ui|bespoke ui|unique ui|complete design freedom|build from scratch|hand[-\s]?rolled)\b|\b(?:our|my|their|its|your)\s+own\s+(?:\w+\s+){0,2}?(?:component(?:\s+tree)?|components?|screens?|widgets?|ui)\b/i,
43
43
  },
44
44
  {
45
45
  id: "basic-theme",
@@ -71,6 +71,10 @@ const SOURCE_EVIDENCE = [
71
71
  "Local UIKit source study: Web provider exposes configs, pageBehavior, syncNetworkConfig, and localization props; React Native, Android, and Flutter repos use config files plus behavior managers/providers.",
72
72
  ];
73
73
  const FEATURE_CAPABILITY = /\b(livestream(?:ing)?|live[-\s]?stream(?:ing)?|video call(?:ing|s)?|voice call(?:ing|s)?|video chat|broadcast(?:ing)?|go live)\b/i;
74
+ const SERVER_DRIVEN_ORIGIN = /\b(remote config|network config|syncNetworkConfig|real[-\s]?time theme|server[-\s]?driven|(?:without|with no|no)\s+(?:an? )?(?:app )?(?:release|rebuild|re-?deploy(?:ment)?|deploy)|update(?:d|s)? remotely|remotely (?:update|change|configure|manage)\w*)\b/i;
75
+ const BUNDLED_CONFIG_ORIGIN = /\b(config\.json|uikit\.config|bundled config|config file)\b/i;
76
+ const THEME_TOKEN_STYLING = /\b(primary_color|preferred_theme|dark mode|light mode|brand colors?|palette|theme)\b/i;
77
+ const STRUCTURAL_STYLING = /\b((?:hide|remove)\b|exclu(?:de|des|ded|ding|sion|sions)|button text|icons?|page id|component id|element id|reactions?|feature flags?)\b/i;
74
78
  export function recommendUIKitCustomization(args) {
75
79
  const answers = args.answers ?? {};
76
80
  const request = typeof args.request === "string" ? args.request : "";
@@ -84,7 +88,8 @@ export function recommendUIKitCustomization(args) {
84
88
  const solutionNeedsUIKitAttention = args.solutionPath?.recommendation === "uikit" ||
85
89
  args.solutionPath?.recommendation === "hybrid" ||
86
90
  args.solutionPath?.recommendation === "needs-decision";
87
- if (!explicitAnswer && args.solutionPath?.recommendation === "sdk") {
91
+ const sp = args.solutionPath;
92
+ if (!explicitAnswer && sp?.recommendation === "sdk" && (sp.signals.sdk.length > 0 || !hasCustomizationSignal)) {
88
93
  return undefined;
89
94
  }
90
95
  if (!explicitAnswer && !unrecognizedExplicitAnswer && (!hasCustomizationSignal || sdkCustomUiOnly) && !solutionNeedsUIKitAttention) {
@@ -94,7 +99,7 @@ export function recommendUIKitCustomization(args) {
94
99
  const matchedLevels = levelsFromMatches(matches);
95
100
  const answeredLevel = explicitAnswer;
96
101
  const recommendedLevel = answeredLevel
97
- ?? (unrecognizedExplicitAnswer || featureCapabilityOnly ? "needs-decision" : chooseRecommendedLevel(matches, args.solutionPath));
102
+ ?? (unrecognizedExplicitAnswer || featureCapabilityOnly ? "needs-decision" : chooseRecommendedLevel(matches, request, args.solutionPath));
98
103
  const additionalLevels = matchedLevels.filter((level) => level !== recommendedLevel);
99
104
  const status = statusFor(recommendedLevel, matchedLevels, args.solutionPath, explicitAnswer, unrecognizedExplicitAnswer);
100
105
  const confidence = confidenceFor({ explicitAnswer, matches, recommendedLevel, status, solutionPath: args.solutionPath, unrecognizedExplicitAnswer });
@@ -127,9 +132,11 @@ export function recommendUIKitCustomization(args) {
127
132
  advisoryOnly: "This is advisory UIKit customization guidance. It does not change outcome classification, compliance rules, sidecar schema, or `vise check` exit codes.",
128
133
  };
129
134
  }
135
+ const NEGATED_CUSTOM_CODE = /\b(?:do not|don'?t|dont|won'?t|wont|can'?t|cant|cannot|no|without|not|never|avoid|skip)\b(?:\s+\w+){0,3}?\s+(?:run\s+)?custom\s+(?:code|handler|action)\b/gi;
130
136
  function collectSignals(request) {
137
+ const cleaned = request.replace(NEGATED_CUSTOM_CODE, " ");
131
138
  return SIGNALS.flatMap((signal) => {
132
- const match = request.match(signal.pattern);
139
+ const match = cleaned.match(signal.pattern);
133
140
  if (!match?.[0]) {
134
141
  return [];
135
142
  }
@@ -162,7 +169,7 @@ function levelsFromMatches(matches) {
162
169
  "sdk-custom-ui",
163
170
  ].filter((level) => matches.some((match) => match.level === level));
164
171
  }
165
- function chooseRecommendedLevel(matches, solutionPath) {
172
+ function chooseRecommendedLevel(matches, request, solutionPath) {
166
173
  const matchedLevels = levelsFromMatches(matches);
167
174
  const strongLevels = levelsFromMatches(matches.filter((match) => match.strength === "strong"));
168
175
  const pool = strongLevels.length > 0 ? strongLevels : matchedLevels;
@@ -175,6 +182,14 @@ function chooseRecommendedLevel(matches, solutionPath) {
175
182
  if (pool.includes("behavior-overrides")) {
176
183
  return "behavior-overrides";
177
184
  }
185
+ if (pool.includes("dynamic-ui") &&
186
+ pool.includes("component-styling") &&
187
+ SERVER_DRIVEN_ORIGIN.test(request) &&
188
+ !BUNDLED_CONFIG_ORIGIN.test(request) &&
189
+ THEME_TOKEN_STYLING.test(request) &&
190
+ !STRUCTURAL_STYLING.test(request)) {
191
+ return "dynamic-ui";
192
+ }
178
193
  if (pool.includes("component-styling")) {
179
194
  return "component-styling";
180
195
  }
@@ -317,6 +332,7 @@ function platformGuidanceFor(platform) {
317
332
  return [
318
333
  "Web/TypeScript UIKit: pass local config through `AmityUIKitProvider configs`, enable `syncNetworkConfig` for Dynamic UI, pass `pageBehavior` for navigation/action overrides, and use the `localization` prop for locale bundles or string overrides.",
319
334
  "Web source anchors: `Amity-Social-Cloud-UIKit-Web/readme.md`, `src/v4/core/providers/AmityUIKitProvider.tsx`, `src/v4/core/providers/CustomizationProvider`, `src/v4/core/providers/PageBehaviorProvider.tsx`, and `src/v4/constants/customization.ts`.",
335
+ "Web/TypeScript UIKit install caveats (npm + bundler): (1) the published UIKit (`@amityco/ui-kit-open-source`) has peer-dependency ranges that conflict with `@amityco/ts-sdk`, so `npm install` may need `--legacy-peer-deps`; (2) it pulls `react-intl`, which requires `tslib` — install `tslib` explicitly, or a modern bundler (Vite 8 / rolldown, esbuild) hard-fails the build with an unresolved `tslib`; (3) a partial `configs` theme can trip the exported `ConfigurableThemeValue` type (it expects the full color-slot set) — provide all slots or cast. These are install/build-time only and do not affect compliance.",
320
336
  ];
321
337
  }
322
338
  function labelForLevel(level) {
package/dist/version.js CHANGED
@@ -1,9 +1,9 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
4
5
  function readPackageMetadata() {
5
- const moduleDir = path.dirname(fileURLToPath(import.meta.url));
6
- const packageJsonPath = path.resolve(moduleDir, "..", "package.json");
6
+ const packageJsonPath = path.join(packageRoot, "package.json");
7
7
  const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
8
8
  if (typeof parsed.name !== "string" || typeof parsed.version !== "string") {
9
9
  throw new Error(`Invalid package metadata in ${packageJsonPath}.`);
@@ -17,3 +17,7 @@ const packageMetadata = readPackageMetadata();
17
17
  export const packageName = packageMetadata.name;
18
18
  export const packageVersion = packageMetadata.version;
19
19
  export const packageUserAgent = `social-plus-vise/${packageVersion}`;
20
+ export function detectBuildSource(root) {
21
+ return existsSync(path.join(root, "src")) ? "local" : "published";
22
+ }
23
+ export const buildSource = detectBuildSource(packageRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amityco/social-plus-vise",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Skill-guided deterministic CLI for social.plus SDK integration assistance.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "description": "Free vocabulary today (the guard does not enum it). Current values: content, participation, knowledge, identity, conversation, live, commitment, progression, incentive, recognition, competition, activation."
96
96
  },
97
97
  "surface": {
98
- "enum": ["feed", "comments", "chat", "profile", "community", "notifications"],
98
+ "enum": ["feed", "livestream", "comments", "chat", "profile", "community", "notifications"],
99
99
  "description": "UI surface this object is placed on. Present only on SDK-backed objects (its presence is what makes an object SOCIAL_PLUS_OBJECTS). Source of truth for outcomesForObjects/surfaceIdsForObjects/CREATIVE_SURFACE_HINTS, derived in src/intelligence/placement.ts. The surface set is closed (each is bound to an Outcome in code) — adding a new surface is an engine decision, not a catalog edit."
100
100
  },
101
101
  "block": {
@@ -4,13 +4,13 @@
4
4
  "items": [
5
5
  { "id": "feed", "name": "Feed", "description": "A stream of social posts or updates.", "category": "content", "surface": "feed", "block": "feed" },
6
6
  { "id": "comment", "name": "Comment", "description": "A response thread attached to content or an object.", "category": "participation", "surface": "comments", "block": "comments" },
7
- { "id": "forum", "name": "Forum", "description": "A structured discussion space optimized for topics and knowledge.", "category": "knowledge", "surface": "community" },
7
+ { "id": "forum", "name": "Forum", "description": "A structured discussion space optimized for topics and knowledge.", "category": "knowledge", "surface": "feed" },
8
8
  { "id": "community", "name": "Community", "description": "A persistent group identity around people, content, or purpose.", "category": "identity", "surface": "community", "block": "community" },
9
9
  { "id": "creator", "name": "Creator", "description": "A person or entity that owns an audience relationship.", "category": "identity", "surface": "profile", "block": "profile" },
10
10
  { "id": "profile", "name": "Profile", "description": "A user or creator identity surface with social relationship signals.", "category": "identity", "surface": "profile", "block": "profile" },
11
11
  { "id": "chat", "name": "Chat", "description": "Direct or group conversation.", "category": "conversation", "surface": "chat", "block": "chat" },
12
12
  { "id": "story", "name": "Story", "description": "Ephemeral or sequenced media updates.", "category": "content", "surface": "feed" },
13
- { "id": "livestream", "name": "Livestream", "description": "Real-time video or audio participation.", "category": "live", "surface": "feed", "block": "livestream" },
13
+ { "id": "livestream", "name": "Livestream", "description": "Real-time video or audio participation.", "category": "live", "surface": "livestream", "block": "livestream" },
14
14
  { "id": "event", "name": "Event", "description": "A scheduled social moment.", "category": "live", "surface": "notifications", "block": "events" },
15
15
  { "id": "rsvp", "name": "RSVP", "description": "A user's intent to attend or participate in an event.", "category": "commitment", "surface": "notifications", "block": "events" },
16
16
  { "id": "challenge", "name": "Challenge", "description": "A time-bounded task or contest.", "category": "participation", "availability": "in-development" },
@@ -127,6 +127,30 @@
127
127
  "uxPatternIds": ["dedicated-community-tab", "event-center"],
128
128
  "tradeoffs": ["serves fitness retention today on shipped primitives, without gamification", "competition/streak mechanics arrive when social.plus gamification ships"],
129
129
  "whenToChoose": "Choose for a fitness or wellness community on shipped primitives: group classes, live workout sessions, an events calendar, and a space for peer accountability with member profiles. Best when retention comes from showing up together, with no competition or streak/points mechanics."
130
+ },
131
+ {
132
+ "id": "messaging-first",
133
+ "name": "Messaging First",
134
+ "description": "Make real-time conversation the primary social surface: 1:1 and group messaging with an inbox as the home base.",
135
+ "businessGoalIds": ["participation", "retention"],
136
+ "archetypeIds": ["enterprise", "gaming", "media-streaming", "education"],
137
+ "solutionPatternIds": ["peer-support", "live-participation"],
138
+ "experienceObjectIds": ["chat", "profile", "notification", "community"],
139
+ "uxPatternIds": ["inbox-first-chat"],
140
+ "tradeoffs": ["fastest path to daily active conversation and retention", "needs presence/notification investment and moderation for direct messages"],
141
+ "whenToChoose": "Choose when direct conversation is the primary destination: 1:1 and group chat, messaging/DMs, an inbox or threads as the home surface, real-time presence and typing. Best when people come to talk to each other rather than to browse a feed."
142
+ },
143
+ {
144
+ "id": "social-graph-first",
145
+ "name": "Social Graph First",
146
+ "description": "Make following people and creators the backbone: rich profiles and a follow/unfollow graph that powers a personalized following feed.",
147
+ "businessGoalIds": ["creator-growth", "retention", "participation"],
148
+ "archetypeIds": ["creator", "media-streaming", "fitness", "loyalty"],
149
+ "solutionPatternIds": ["audience-ownership", "recognition"],
150
+ "experienceObjectIds": ["profile", "creator", "feed", "notification"],
151
+ "uxPatternIds": ["profile-social-header"],
152
+ "tradeoffs": ["personalized, relationship-driven distribution that compounds with each follow", "cold-start risk until users follow enough accounts to fill the following feed"],
153
+ "whenToChoose": "Choose when the follow relationship is the backbone of the experience: user and creator profiles, follow/unfollow, a personalized following feed, and follower-driven notifications. Best when distribution should come from who you follow rather than from a single shared community feed."
130
154
  }
131
155
  ]
132
156
  }
package/rules/event.yaml CHANGED
@@ -5,6 +5,7 @@
5
5
  {
6
6
  "id": "typescript.event.live-collection",
7
7
  "version": 1,
8
+ "symbol_anchored": { "platform": "typescript", "types": ["EventRepository"], "members": ["getEvents"], "basis": "names-only" },
8
9
  "title": "TypeScript event calendars / attendee lists should use the reactive Event LiveCollection",
9
10
  "severity": "warning",
10
11
  "rationale": "Event calendars (getEvents) and attendee/RSVP lists (getRSVPs) are Live Collections — rsvpCount and the going/not-going roster update as people RSVP. A one-shot fetch (no callback) renders a list that never updates. Pass the live callback: EventRepository.getEvents(params, callback).",
@@ -18,6 +19,7 @@
18
19
  {
19
20
  "id": "android.event.live-collection",
20
21
  "version": 1,
22
+ "symbol_anchored": { "platform": "android", "types": ["AmityEventRepository"], "members": ["getEvents"], "basis": "names-only" },
21
23
  "title": "Android event calendars / attendee lists should use the reactive Event LiveCollection",
22
24
  "severity": "warning",
23
25
  "rationale": "Event calendars (getEvents) and attendee/RSVP lists (getRSVPs) are Live Collections — rsvpCount and the going/not-going roster update as people RSVP. A one-shot fetch renders a list that never updates. Observe the collection instead (PagingData via .doOnNext { ... }.subscribe(), or getRSVPs(...).observe()).",
@@ -31,6 +33,7 @@
31
33
  {
32
34
  "id": "ios.event.live-collection",
33
35
  "version": 1,
36
+ "symbol_anchored": { "platform": "ios", "types": ["AmityEventRepository"], "members": ["getEvents"], "basis": "names-only" },
34
37
  "title": "iOS event calendars / attendee lists should use the reactive Event LiveCollection",
35
38
  "severity": "warning",
36
39
  "rationale": "Event calendars (getEvents) and attendee/RSVP lists (getRSVPs) are Live Collections — rsvpCount and the going/not-going roster update as people RSVP. A one-shot snapshot renders a list that never updates. Observe the collection instead: getEvents(...).observe { ... } / getRSVPs(...).observe { ... }.",