@amityco/social-plus-vise 0.14.4 → 0.14.6

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.
@@ -0,0 +1,385 @@
1
+ import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { packageVersion } from "../version.js";
4
+ import { inspectProject } from "./project.js";
5
+ import { detectCommandSensors } from "./harness.js";
6
+ import { readDesignContract } from "./design.js";
7
+ const registryPlatformByVisePlatform = {
8
+ typescript: "react",
9
+ "react-native": "react-native",
10
+ flutter: "flutter",
11
+ };
12
+ export async function listRegistryBlocks(registryPath) {
13
+ const registry = await loadRegistry(registryPath);
14
+ return {
15
+ source: "social-plus-block-factory",
16
+ mode: "block-registry",
17
+ schemaVersion: registry.schemaVersion,
18
+ blocks: registry.blocks.map((block) => ({
19
+ blockId: block.blockId,
20
+ version: block.version,
21
+ status: block.status,
22
+ surfaces: block.surfaces,
23
+ requiredSdkCapabilities: block.requiredSdkCapabilities,
24
+ events: block.events,
25
+ })),
26
+ };
27
+ }
28
+ export async function planBlockInstall(options) {
29
+ return buildInstallPlan(options, "plan");
30
+ }
31
+ export async function addBlockInstall(options) {
32
+ const mode = options.apply ? "apply" : "dry-run";
33
+ const plan = await buildInstallPlan(options, mode);
34
+ if (!options.apply) {
35
+ return plan;
36
+ }
37
+ if (plan.status !== "ready") {
38
+ return {
39
+ ...plan,
40
+ applied: false,
41
+ reason: "Block install needs review before Vise can safely write files.",
42
+ };
43
+ }
44
+ const repoPath = requiredRepoPath(options);
45
+ const filesTouched = [];
46
+ filesTouched.push(...(await applyPackageChange(repoPath, plan)));
47
+ filesTouched.push(...(await applySourceChanges(repoPath, plan)));
48
+ filesTouched.push(await writeBlocksSidecar(repoPath, plan, options.packageSource ?? "npm", filesTouched));
49
+ return {
50
+ ...plan,
51
+ applied: true,
52
+ filesTouched,
53
+ };
54
+ }
55
+ export async function validateBlockInstall(options) {
56
+ if (!options.blockId) {
57
+ const repoPath = requiredRepoPath(options);
58
+ const registry = await loadRegistry(options.registryPath);
59
+ const sidecar = await readSidecar(repoPath);
60
+ const installed = sidecar?.installed ?? [];
61
+ const knownBlocks = new Set(registry.blocks.map((block) => block.blockId));
62
+ const findings = installed
63
+ .filter((entry) => !knownBlocks.has(entry.blockId))
64
+ .map((entry) => ({
65
+ ruleId: "blocks.registry.known",
66
+ severity: "warning",
67
+ message: `Installed sidecar entry references unknown registry block ${entry.blockId}.`,
68
+ recommendation: "Update the Block Factory registry or remove stale block sidecar state.",
69
+ }));
70
+ if (installed.length === 0) {
71
+ findings.push({
72
+ ruleId: "blocks.sidecar.installed",
73
+ severity: "warning",
74
+ message: "No installed block sidecar entries exist.",
75
+ recommendation: "Run `vise blocks add <repoPath> --block <id> --apply` after reviewing the dry-run plan.",
76
+ });
77
+ }
78
+ return {
79
+ status: findings.some((finding) => finding.severity === "error") ? "needs-review" : "ready",
80
+ mode: "validate",
81
+ registryBlocks: registry.blocks.map((block) => block.blockId),
82
+ sidecarPath: path.join("sp-vise", "blocks.json"),
83
+ sidecarInstalled: installed,
84
+ findings,
85
+ };
86
+ }
87
+ const plan = await buildInstallPlan(options, "validate");
88
+ const repoPath = requiredRepoPath(options);
89
+ const sidecar = await readSidecar(repoPath);
90
+ const installed = (sidecar?.installed ?? []).filter((entry) => !options.blockId || entry.blockId === options.blockId);
91
+ const findings = [];
92
+ if (installed.length === 0) {
93
+ findings.push({
94
+ ruleId: "blocks.sidecar.installed",
95
+ severity: "warning",
96
+ message: options.blockId ? `No sidecar entry exists for block ${options.blockId}.` : "No installed block sidecar entries exist.",
97
+ recommendation: "Run `vise blocks add <repoPath> --block <id> --apply` after reviewing the dry-run plan.",
98
+ });
99
+ }
100
+ if (plan.packageChange.alreadyPresent === false) {
101
+ findings.push({
102
+ ruleId: "blocks.package.present",
103
+ severity: "error",
104
+ message: `Required package dependency is missing: ${plan.packageChange.dependency}.`,
105
+ recommendation: `Install ${plan.packageChange.dependency} in ${plan.packageChange.file}.`,
106
+ });
107
+ }
108
+ for (const targetFile of plan.targetFiles) {
109
+ if (targetFile.operation === "needs-review") {
110
+ findings.push({
111
+ ruleId: "blocks.anchor.present",
112
+ severity: "error",
113
+ message: `Missing safe install anchor ${targetFile.anchor} in ${targetFile.path}.`,
114
+ recommendation: "Add an explicit social.plus block install anchor or install the block manually and re-run validation.",
115
+ });
116
+ }
117
+ }
118
+ const hasError = findings.some((finding) => finding.severity === "error");
119
+ return {
120
+ ...plan,
121
+ status: hasError ? "needs-review" : plan.status,
122
+ validationStatus: hasError ? "failed" : "passed",
123
+ sidecarInstalled: installed,
124
+ findings,
125
+ };
126
+ }
127
+ async function buildInstallPlan(options, mode) {
128
+ const repoPath = requiredRepoPath(options);
129
+ const registry = await loadRegistry(options.registryPath);
130
+ const block = findBlock(registry, options.blockId);
131
+ const inspection = await inspectProject(repoPath, options.surfacePath);
132
+ const platform = inspection.platforms[0] ?? "unknown";
133
+ const registryPlatform = registryPlatformByVisePlatform[platform];
134
+ const sensors = await detectCommandSensors(inspection.effectiveRoot, inspection.platforms);
135
+ const stopConditions = [];
136
+ if (!registryPlatform) {
137
+ stopConditions.push(`Unsupported Vise blocks installer platform: ${platform}. Supported installer platforms: react, react-native, flutter.`);
138
+ }
139
+ const packageInfo = registryPlatform ? block.packages[registryPlatform] : undefined;
140
+ if (!packageInfo || !registryPlatform) {
141
+ stopConditions.push(`Block ${block.blockId} does not provide installer metadata for detected platform ${platform}.`);
142
+ }
143
+ const safePackage = packageInfo ?? {
144
+ packageName: "",
145
+ dependencyName: "",
146
+ publicEntrypoint: "",
147
+ importName: "",
148
+ installAnchor: "",
149
+ };
150
+ const packageChange = await packageChangeFor(inspection.effectiveRoot, registryPlatform, safePackage, options.packageSource);
151
+ const targetFiles = await targetFilesFor(inspection.effectiveRoot, block.blockId, registryPlatform, safePackage);
152
+ for (const targetFile of targetFiles) {
153
+ if (targetFile.operation === "needs-review") {
154
+ stopConditions.push(`Missing safe install anchor ${targetFile.anchor} in ${targetFile.path}.`);
155
+ }
156
+ }
157
+ return {
158
+ status: stopConditions.length > 0 ? "needs-review" : "ready",
159
+ mode,
160
+ block: {
161
+ id: block.blockId,
162
+ version: block.version,
163
+ status: block.status,
164
+ },
165
+ platform,
166
+ registryPlatform: registryPlatform ?? "react",
167
+ package: safePackage,
168
+ packageSource: options.packageSource,
169
+ packageChange,
170
+ targetFiles,
171
+ sensors: sensors.map((sensor) => ({ name: sensor.name, command: sensor.command, source: sensor.source })),
172
+ stopConditions,
173
+ sidecarPath: path.join("sp-vise", "blocks.json"),
174
+ };
175
+ }
176
+ async function loadRegistry(registryPath) {
177
+ if (!registryPath) {
178
+ throw new Error("blocks command requires --registry <path>.");
179
+ }
180
+ const resolved = path.resolve(registryPath);
181
+ if (!(await exists(resolved))) {
182
+ throw new Error(`Block registry not found: ${registryPath}. Local registry paths are supported in this MVP.`);
183
+ }
184
+ const registryFile = (await stat(resolved)).isDirectory() ? path.join(resolved, "blocks.json") : resolved;
185
+ const registry = JSON.parse(await readFile(registryFile, "utf8"));
186
+ validateRegistry(registry, registryFile);
187
+ return registry;
188
+ }
189
+ function validateRegistry(registry, registryFile) {
190
+ if (registry.schemaVersion !== "2026-06-04.block-registry.v1") {
191
+ throw new Error(`${registryFile}: unsupported block registry schemaVersion ${registry.schemaVersion}`);
192
+ }
193
+ if (!Array.isArray(registry.blocks) || registry.blocks.length === 0) {
194
+ throw new Error(`${registryFile}: registry.blocks must be a non-empty array`);
195
+ }
196
+ for (const block of registry.blocks) {
197
+ if (!block.blockId || !block.version || !Array.isArray(block.surfaces)) {
198
+ throw new Error(`${registryFile}: every block requires blockId, version, and surfaces`);
199
+ }
200
+ }
201
+ }
202
+ function findBlock(registry, blockId) {
203
+ if (!blockId) {
204
+ throw new Error("blocks command requires --block <id> for plan/add/validate.");
205
+ }
206
+ const block = registry.blocks.find((entry) => entry.blockId === blockId);
207
+ if (!block) {
208
+ throw new Error(`Unknown block in registry: ${blockId}`);
209
+ }
210
+ return block;
211
+ }
212
+ async function packageChangeFor(root, platform, packageInfo, packageSource) {
213
+ if (platform === "flutter") {
214
+ const file = "pubspec.yaml";
215
+ const source = await readFile(path.join(root, file), "utf8").catch(() => "");
216
+ return {
217
+ file,
218
+ dependency: packageInfo.dependencyName,
219
+ value: dependencyValue(root, packageInfo, packageSource, platform),
220
+ alreadyPresent: new RegExp(`\\b${escapeRegExp(packageInfo.dependencyName)}\\s*:`).test(source),
221
+ };
222
+ }
223
+ const file = "package.json";
224
+ const packageJson = await readPackageJson(path.join(root, file));
225
+ return {
226
+ file,
227
+ dependency: packageInfo.dependencyName,
228
+ value: dependencyValue(root, packageInfo, packageSource, platform),
229
+ alreadyPresent: Boolean(packageJson.dependencies?.[packageInfo.dependencyName]),
230
+ };
231
+ }
232
+ async function targetFilesFor(root, blockId, platform, packageInfo) {
233
+ const sourceFile = sourceFileFor(platform);
234
+ if (!sourceFile) {
235
+ return [];
236
+ }
237
+ const absolutePath = path.join(root, sourceFile);
238
+ const source = await readFile(absolutePath, "utf8").catch(() => "");
239
+ const blockPresent = packageInfo.publicEntrypoint !== "" && source.includes(packageInfo.publicEntrypoint);
240
+ const anchorPresent = packageInfo.installAnchor !== "" && source.includes(packageInfo.installAnchor);
241
+ return [
242
+ {
243
+ path: sourceFile,
244
+ anchor: packageInfo.installAnchor,
245
+ operation: blockPresent ? "already-present" : anchorPresent ? "insert" : "needs-review",
246
+ },
247
+ ];
248
+ }
249
+ async function applyPackageChange(repoPath, plan) {
250
+ const filePath = path.join(repoPath, plan.packageChange.file);
251
+ if (plan.packageChange.alreadyPresent) {
252
+ return [];
253
+ }
254
+ if (plan.registryPlatform === "flutter") {
255
+ const source = await readFile(filePath, "utf8");
256
+ const next = addPubspecDependency(source, plan.packageChange.dependency, plan.packageChange.value);
257
+ await writeFile(filePath, next, "utf8");
258
+ return [plan.packageChange.file];
259
+ }
260
+ const packageJson = await readPackageJson(filePath);
261
+ packageJson.dependencies = {
262
+ ...(packageJson.dependencies ?? {}),
263
+ [plan.packageChange.dependency]: plan.packageChange.value,
264
+ };
265
+ await writeFile(filePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
266
+ return [plan.packageChange.file];
267
+ }
268
+ async function applySourceChanges(repoPath, plan) {
269
+ const touched = [];
270
+ for (const targetFile of plan.targetFiles) {
271
+ if (targetFile.operation !== "insert") {
272
+ continue;
273
+ }
274
+ const filePath = path.join(repoPath, targetFile.path);
275
+ const source = await readFile(filePath, "utf8");
276
+ const next = insertBlockSnippet(source, plan);
277
+ if (next !== source) {
278
+ await writeFile(filePath, next, "utf8");
279
+ touched.push(targetFile.path);
280
+ }
281
+ }
282
+ return touched;
283
+ }
284
+ async function writeBlocksSidecar(repoPath, plan, packageSource, filesTouched) {
285
+ const designContract = await readDesignContract(repoPath);
286
+ const sidecarPath = path.join(repoPath, "sp-vise", "blocks.json");
287
+ const existing = await readSidecar(repoPath);
288
+ const sidecar = existing ?? {
289
+ schemaVersion: "2026-06-04.vise-blocks-sidecar.v1",
290
+ viseVersion: packageVersion,
291
+ generatedAt: new Date().toISOString(),
292
+ installed: [],
293
+ };
294
+ const entry = {
295
+ blockId: plan.block.id,
296
+ blockVersion: plan.block.version,
297
+ platform: plan.registryPlatform,
298
+ packageSource,
299
+ filesTouched,
300
+ designContractDigest: designContract?.digest,
301
+ sdkFactsVersion: plan.block.version,
302
+ validationStatus: "installed",
303
+ };
304
+ sidecar.viseVersion = packageVersion;
305
+ sidecar.generatedAt = new Date().toISOString();
306
+ sidecar.installed = [...sidecar.installed.filter((item) => item.blockId !== plan.block.id), entry];
307
+ await mkdir(path.dirname(sidecarPath), { recursive: true });
308
+ await writeFile(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, "utf8");
309
+ return path.join("sp-vise", "blocks.json");
310
+ }
311
+ async function readSidecar(repoPath) {
312
+ const sidecarPath = path.join(repoPath, "sp-vise", "blocks.json");
313
+ try {
314
+ return JSON.parse(await readFile(sidecarPath, "utf8"));
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ function dependencyValue(root, packageInfo, packageSource, platform) {
321
+ if (!packageSource || packageSource === "npm") {
322
+ return platform === "flutter" ? "^0.1.0" : "^0.1.0";
323
+ }
324
+ if (packageSource.startsWith("file:")) {
325
+ return packageSource;
326
+ }
327
+ const resolved = path.resolve(root, packageSource);
328
+ return `file:${path.relative(root, resolved)}`;
329
+ }
330
+ function sourceFileFor(platform) {
331
+ if (platform === "react") {
332
+ return path.join("src", "render.mjs");
333
+ }
334
+ if (platform === "react-native") {
335
+ return path.join("src", "App.mjs");
336
+ }
337
+ if (platform === "flutter") {
338
+ return path.join("lib", "main.dart");
339
+ }
340
+ return null;
341
+ }
342
+ function insertBlockSnippet(source, plan) {
343
+ if (source.includes(plan.package.publicEntrypoint)) {
344
+ return source;
345
+ }
346
+ if (plan.registryPlatform === "react" || plan.registryPlatform === "react-native") {
347
+ const importSnippet = `import { ${plan.package.importName} } from "${plan.package.publicEntrypoint}";`;
348
+ return source.replace(plan.package.installAnchor, `${plan.package.installAnchor}\n${importSnippet}\n// social-plus-vise: ${plan.block.id} source mount requires human review in brownfield apps`);
349
+ }
350
+ if (plan.registryPlatform === "flutter") {
351
+ const importSnippet = `import '${plan.package.publicEntrypoint}';`;
352
+ return source.replace(plan.package.installAnchor, `${plan.package.installAnchor}\n${importSnippet}\n// social-plus-vise: ${plan.block.id} widget mount requires human review in brownfield apps`);
353
+ }
354
+ return source;
355
+ }
356
+ function addPubspecDependency(source, dependencyName, value) {
357
+ if (!source.includes("dependencies:")) {
358
+ return `${source.trimEnd()}\n\ndependencies:\n ${dependencyName}:\n path: ${value.replace(/^file:/, "")}\n`;
359
+ }
360
+ const lines = source.split(/\r?\n/);
361
+ const insertIndex = lines.findIndex((line) => line.trim() === "dependencies:") + 1;
362
+ lines.splice(insertIndex, 0, ` ${dependencyName}:`, ` path: ${value.replace(/^file:/, "")}`);
363
+ return `${lines.join("\n").trimEnd()}\n`;
364
+ }
365
+ async function readPackageJson(filePath) {
366
+ return JSON.parse(await readFile(filePath, "utf8"));
367
+ }
368
+ async function exists(filePath) {
369
+ try {
370
+ await access(filePath);
371
+ return true;
372
+ }
373
+ catch {
374
+ return false;
375
+ }
376
+ }
377
+ function requiredRepoPath(options) {
378
+ if (!options.repoPath) {
379
+ throw new Error("blocks command requires repoPath.");
380
+ }
381
+ return path.resolve(options.repoPath);
382
+ }
383
+ function escapeRegExp(value) {
384
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
385
+ }
@@ -2,11 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { assessProjectCompleteness } from "../capabilities.js";
6
- import { classifyOutcome } from "../outcomes.js";
7
- import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
5
+ import { assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, optionalCapabilityChecklist, selectedOptionalCapabilityIds, } from "../capabilities.js";
6
+ import { classifyOutcome, getOutcomeDefinition, hasAnswer, planContextFor, } from "../outcomes.js";
7
+ import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
8
8
  import { packageVersion } from "../version.js";
9
- import { readDesignContract } from "./design.js";
9
+ import { buildDesignBrief, readDesignContract } from "./design.js";
10
10
  import { inspectProject, validateSetup } from "./project.js";
11
11
  const complianceDirName = "sp-vise";
12
12
  const attestationsDirName = "attestations";
@@ -21,13 +21,22 @@ export const initComplianceTool = {
21
21
  repoPath: { type: "string" },
22
22
  request: { type: "string" },
23
23
  surfacePath: { type: "string" },
24
+ answers: {
25
+ type: "object",
26
+ description: "Optional intake answers from vise plan, including feed_optional_capabilities.",
27
+ additionalProperties: { type: "string" },
28
+ },
29
+ allowUnresolvedIntake: {
30
+ type: "boolean",
31
+ description: "Explicit acknowledgement for retrospective/harness initialization when blocking intake questions remain unresolved.",
32
+ },
24
33
  },
25
34
  required: ["repoPath", "request"],
26
35
  additionalProperties: false,
27
36
  },
28
37
  async call(input) {
29
38
  const args = objectInput(input);
30
- return textResult(await initCompliance(stringField(args, "repoPath"), stringField(args, "request"), optionalStringField(args, "surfacePath")));
39
+ return textResult(await initCompliance(stringField(args, "repoPath"), stringField(args, "request"), optionalStringField(args, "surfacePath"), answersFromInput(args.answers), { allowUnresolvedIntake: optionalBooleanField(args, "allowUnresolvedIntake") }));
31
40
  },
32
41
  };
33
42
  export const checkComplianceTool = {
@@ -46,6 +55,18 @@ export const checkComplianceTool = {
46
55
  return textResult(await checkCompliance(stringField(args, "repoPath")));
47
56
  },
48
57
  };
58
+ function answersFromInput(raw) {
59
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
60
+ return {};
61
+ }
62
+ const answers = {};
63
+ for (const [key, value] of Object.entries(raw)) {
64
+ if (typeof value === "string" && value.trim() !== "") {
65
+ answers[key] = value;
66
+ }
67
+ }
68
+ return answers;
69
+ }
49
70
  export const syncComplianceTool = {
50
71
  name: "sync_compliance",
51
72
  description: "Persist current deterministic-pass compliance results into the sp-vise sidecar.",
@@ -225,15 +246,34 @@ async function readEngagement(repoRoot) {
225
246
  function engagementPath(repoRoot) {
226
247
  return path.join(sidecarDir(repoRoot), "engagement.json");
227
248
  }
228
- export async function initCompliance(repoPath, request, surfacePath) {
249
+ export async function initCompliance(repoPath, request, surfacePath, answers = {}, options = {}) {
229
250
  const repoRoot = path.resolve(repoPath);
230
251
  const inspection = await inspectProject(repoRoot, surfacePath);
231
252
  const outcome = classifyOutcome(request);
253
+ const selectedOptionalCapabilities = selectedOptionalCapabilityIds(outcome, answers, request);
232
254
  const rules = await applicableRules(outcome, inspection.platforms);
233
255
  const refs = rules.map(ruleRef); // minimal shape — stable digest input
234
256
  const fileRefs = rules.map(ruleRefForFile); // adds title for human/agent readers
235
257
  const engagement = await readEngagement(repoRoot);
236
258
  const designContract = await readDesignContract(repoRoot);
259
+ const intake = intakeAuditFor({
260
+ request,
261
+ outcome,
262
+ platforms: inspection.platforms,
263
+ designSignals: inspection.designSignals,
264
+ answers,
265
+ designBrief: designContract ? buildDesignBrief(designContract) : undefined,
266
+ allowUnresolvedIntake: options.allowUnresolvedIntake === true,
267
+ });
268
+ if (intake.remainingBlocking > 0 && !intake.acknowledged_unresolved_blocking) {
269
+ return {
270
+ status: "needs-clarification",
271
+ exitCode: 7,
272
+ outcome,
273
+ intake,
274
+ nextStep: "Run `vise plan` and surface the blocking intake questions to the customer. Re-run `vise init` with --answer for each blocking question, or pass --allow-unresolved-intake only for retrospective/harness initialization.",
275
+ };
276
+ }
237
277
  const compliance = {
238
278
  schema_version: schemaVersion,
239
279
  vise_version: packageVersion,
@@ -254,9 +294,16 @@ export async function initCompliance(repoPath, request, surfacePath) {
254
294
  inferred_tokens: designContract.stats.inferred_tokens,
255
295
  }
256
296
  : undefined,
297
+ selected_optional_capabilities: selectedOptionalCapabilities.length > 0 ? selectedOptionalCapabilities : undefined,
257
298
  };
258
299
  await mkdir(attestationsDir(repoRoot), { recursive: true });
259
300
  await writeJson(compliancePath(repoRoot), compliance);
301
+ await writeJson(path.join(sidecarDir(repoRoot), "intake.json"), {
302
+ generated_at: compliance.generated_at,
303
+ request,
304
+ outcome,
305
+ ...intake,
306
+ });
260
307
  await writeJson(path.join(sidecarDir(repoRoot), "inspection.json"), inspection);
261
308
  await writeFile(path.join(sidecarDir(repoRoot), "README.md"), sidecarReadme(compliance), "utf8");
262
309
  // Write a frozen check snapshot so agents can see current rule status immediately
@@ -282,10 +329,83 @@ export async function initCompliance(repoPath, request, surfacePath) {
282
329
  rules: refs.length,
283
330
  engagement_id: engagement?.engagement_id,
284
331
  ...(compliance.design_contract && { design_contract: compliance.design_contract }),
332
+ ...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
333
+ intake: {
334
+ status: intake.status,
335
+ remainingBlocking: intake.remainingBlocking,
336
+ acknowledged_unresolved_blocking: intake.acknowledged_unresolved_blocking,
337
+ questions: intake.questions,
338
+ answers: intake.answers,
339
+ },
285
340
  ...(warnings.length > 0 && { warnings }),
286
341
  nextStep: "Run vise check, then implement until rules pass deterministically or are attested.",
287
342
  };
288
343
  }
344
+ function intakeAuditFor(args) {
345
+ const platform = preferredPlatform(args.platforms);
346
+ const ctx = planContextFor({
347
+ request: args.request,
348
+ outcome: args.outcome,
349
+ platform,
350
+ platforms: args.platforms,
351
+ designSignals: args.designSignals,
352
+ answers: args.answers,
353
+ });
354
+ const questions = [...getOutcomeDefinition(args.outcome).intakeQuestions(ctx)];
355
+ if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
356
+ questions.push({
357
+ id: "design_source",
358
+ question: "Where are the app's design tokens, theme, or reusable UI components defined?",
359
+ why: "The user asked to match the existing design, and no local design source was detected.",
360
+ required: true,
361
+ blocksImplementationWhenMissing: true,
362
+ });
363
+ }
364
+ if (ctx.mentionsDesign && ctx.designSignals.length > 0 && !hasAnswer(ctx.answers, "confirm_design_source")) {
365
+ questions.push({
366
+ id: "confirm_design_source",
367
+ question: `Should the social UI use the detected design source(s): ${ctx.designSignals.map((signal) => signal.file).join(", ")}?`,
368
+ why: "Vise found likely design evidence, but the user or host agent should confirm it is the right source before UI edits.",
369
+ required: true,
370
+ blocksImplementationWhenMissing: false,
371
+ options: ["yes", "use another source"],
372
+ });
373
+ }
374
+ if (args.designBrief &&
375
+ (args.outcome === "add-feed" || args.outcome === "add-chat") &&
376
+ !args.designBrief.roles.some((role) => role.role === "primaryAction") &&
377
+ !hasAnswer(ctx.answers, "primary_action_token")) {
378
+ questions.push({
379
+ id: "primary_action_token",
380
+ question: "Which design token (or color value) should be used as the primary action color? No primary-action token was confidently identified in the design contract.",
381
+ why: "A primary action colour is needed for interactive elements (composer button, own-message bubble). Without a confident token, the agent must guess or omit it.",
382
+ required: false,
383
+ blocksImplementationWhenMissing: false,
384
+ });
385
+ }
386
+ if (args.outcome === "add-feed" && optionalCapabilityChecklist(args.outcome).length > 0 && !hasAnswer(ctx.answers, "feed_optional_capabilities")) {
387
+ questions.push({
388
+ id: "feed_optional_capabilities",
389
+ question: "Which optional feed capabilities should be in scope: post-image-upload, post-poll-creation, post-edit, or none?",
390
+ why: "These capabilities are useful for full feeds, but should become enforceable only after the customer explicitly opts in.",
391
+ required: false,
392
+ blocksImplementationWhenMissing: false,
393
+ options: ["none", ...optionalCapabilityChecklist(args.outcome).map((capability) => capability.id)],
394
+ });
395
+ }
396
+ const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
397
+ return {
398
+ status: remainingBlocking > 0 ? "needs-clarification" : "ready",
399
+ questions,
400
+ answers: args.answers,
401
+ remainingBlocking,
402
+ acknowledged_unresolved_blocking: args.allowUnresolvedIntake && remainingBlocking > 0,
403
+ };
404
+ }
405
+ function preferredPlatform(platforms) {
406
+ const order = ["flutter", "android", "typescript", "react-native", "ios"];
407
+ return order.find((platform) => platforms.includes(platform)) ?? platforms[0] ?? "unknown";
408
+ }
289
409
  export async function applicableComplianceRuleSummaries(outcome, platforms) {
290
410
  return (await applicableRules(outcome, platforms)).map(ruleRefForFile);
291
411
  }
@@ -454,31 +574,48 @@ export async function checkCompliance(repoPath) {
454
574
  const hasDeterministicFailure = results.some((result) => result.status === "deterministic-fail");
455
575
  // "advisory" status is intentionally excluded — advisory rules surface but never block.
456
576
  const needsAttestation = results.some((result) => result.status === "attestation-needed" || result.status === "stale");
457
- // Precedence: blocked (3) > deterministic-failures (2) > needs-attestation (1) > completeness-gap (5) > green (0).
577
+ // Precedence: blocked (3) > deterministic-failures (2) > needs-attestation (1) >
578
+ // completeness-gap (5) > selected-capability-failures (6) > green (0).
458
579
  // Contract drift (exit 4) is handled earlier and short-circuits the loop.
459
580
  // Completeness-gap: capabilities that are neither present nor validly opted-out require an explicit decision
460
581
  // (build it, or place // vise: scope-omit <id> — <reason>). The scope-omit escape hatch keeps this
461
582
  // FP-free because any capability can be excluded with a recorded reason. Failure to assess is silently ignored.
462
583
  const completeness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
463
584
  const hasCompletenessGap = (completeness?.missing.length ?? 0) > 0;
585
+ const selectedOptionalCapabilities = (await assessProjectSelectedOptionalCapabilities(inspection.effectiveRoot, compliance.outcome, compliance.selected_optional_capabilities ?? []).catch(() => null)) ?? undefined;
586
+ const hasSelectedOptionalFailures = ((selectedOptionalCapabilities?.failed.length ?? 0) > 0) || ((selectedOptionalCapabilities?.unknown.length ?? 0) > 0);
464
587
  // Blocked wins because the agent cannot proceed without customer input;
465
588
  // surfacing a smaller failure first would distract from the real blocker.
589
+ const status = hasBlocked
590
+ ? "blocked"
591
+ : hasDeterministicFailure
592
+ ? "deterministic-failures"
593
+ : needsAttestation
594
+ ? "needs-attestation"
595
+ : hasCompletenessGap
596
+ ? "completeness-gap"
597
+ : hasSelectedOptionalFailures
598
+ ? "selected-capability-failures"
599
+ : "green";
466
600
  return {
467
- status: hasBlocked
468
- ? "blocked"
601
+ status,
602
+ exitCode: hasBlocked
603
+ ? 3
469
604
  : hasDeterministicFailure
470
- ? "deterministic-failures"
605
+ ? 2
471
606
  : needsAttestation
472
- ? "needs-attestation"
607
+ ? 1
473
608
  : hasCompletenessGap
474
- ? "completeness-gap"
475
- : "green",
476
- exitCode: hasBlocked ? 3 : hasDeterministicFailure ? 2 : needsAttestation ? 1 : hasCompletenessGap ? 5 : 0,
609
+ ? 5
610
+ : hasSelectedOptionalFailures
611
+ ? 6
612
+ : 0,
477
613
  outcome: compliance.outcome,
478
614
  surfacePath: compliance.surface?.path,
479
615
  summary,
480
616
  rules: results,
481
617
  ...(completeness && (completeness.missing.length > 0 || completeness.optedOut.length > 0 || completeness.present.length > 0) ? { completeness } : {}),
618
+ ...(selectedOptionalCapabilities ? { selectedOptionalCapabilities } : {}),
482
619
  };
483
620
  }
484
621
  export async function syncCompliance(repoPath) {