@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.
- package/CHANGELOG.md +28 -0
- package/README.md +52 -11
- package/dist/capabilities.js +126 -12
- package/dist/server.js +83 -5
- package/dist/tools/blocks.js +385 -0
- package/dist/tools/compliance.js +151 -14
- package/dist/tools/design.js +19 -3
- package/dist/tools/integration.js +36 -4
- package/package.json +3 -2
- package/skills/social-plus-vise/SKILL.md +10 -4
|
@@ -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
|
+
}
|
package/dist/tools/compliance.js
CHANGED
|
@@ -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) >
|
|
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
|
|
468
|
-
|
|
601
|
+
status,
|
|
602
|
+
exitCode: hasBlocked
|
|
603
|
+
? 3
|
|
469
604
|
: hasDeterministicFailure
|
|
470
|
-
?
|
|
605
|
+
? 2
|
|
471
606
|
: needsAttestation
|
|
472
|
-
?
|
|
607
|
+
? 1
|
|
473
608
|
: hasCompletenessGap
|
|
474
|
-
?
|
|
475
|
-
:
|
|
476
|
-
|
|
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) {
|