@amityco/social-plus-vise 1.6.0 → 1.8.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.
- package/CHANGELOG.md +44 -0
- package/README.md +10 -4
- package/dist/flow.js +6 -0
- package/dist/server.js +71 -18
- package/dist/tools/blocks.js +82 -9
- package/dist/tools/compliance.js +315 -27
- package/dist/tools/internalWorkspace.js +44 -0
- package/dist/tools/project.js +116 -29
- package/dist/tools/sdkFacts.js +233 -7
- package/dist/tools/sensors.js +102 -36
- package/package.json +2 -2
- package/packages/intelligence/catalog/catalog.schema.json +6 -0
- package/packages/intelligence/catalog/experience-objects.json +11 -11
- package/sdk-surface/manifest.json +4 -4
- package/sdk-surface/models.typescript.json +2903 -463
- package/skills/social-plus-vise/SKILL.md +20 -0
package/dist/tools/compliance.js
CHANGED
|
@@ -16,7 +16,7 @@ import { packageVersion } from "../version.js";
|
|
|
16
16
|
import { readFlowState } from "../flow.js";
|
|
17
17
|
import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
|
|
18
18
|
import { installedBlockProvidedCapabilities } from "./blocks.js";
|
|
19
|
-
import { inspectProject,
|
|
19
|
+
import { inspectProject, validateSetupWithCoverage, } from "./project.js";
|
|
20
20
|
import { readCreativeSelection } from "./creative.js";
|
|
21
21
|
import { readRuntimeProofWaiver, hasExecutableSmokeMarker } from "./smoke.js";
|
|
22
22
|
import { assessUxHarness, buildExperienceReport, buildUxHarness, readUxHarness, } from "./uxHarness.js";
|
|
@@ -326,6 +326,18 @@ function designReviewFor(repoRoot, contract, answers) {
|
|
|
326
326
|
export async function initCompliance(repoPath, request, surfacePath, answers = {}, options = {}) {
|
|
327
327
|
const repoRoot = path.resolve(repoPath);
|
|
328
328
|
const inspection = await inspectProject(repoRoot, surfacePath);
|
|
329
|
+
if (!inspection.scanCoverage.complete) {
|
|
330
|
+
const truncated = inspection.scanCoverage.scans.filter((scan) => scan.truncated);
|
|
331
|
+
return {
|
|
332
|
+
status: "needs-clarification",
|
|
333
|
+
exitCode: 7,
|
|
334
|
+
reason: `Vise could not inspect the complete project inventory: ${truncated
|
|
335
|
+
.map((scan) => `${scan.scope} reached its ${scan.limit}-file limit under ${scan.root}`)
|
|
336
|
+
.join("; ")}.`,
|
|
337
|
+
instruction: "Select the specific app/workspace surface before initializing a compliance contract; an incomplete platform inventory cannot be baked into a greenable sidecar.",
|
|
338
|
+
nextStep: "Re-run `vise init` with --surface <app-path> (or --surface-path <app-path>) after narrowing the project.",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
329
341
|
const outcome = resolveOutcome(request, answers);
|
|
330
342
|
const platform = preferredPlatform(inspection.platforms);
|
|
331
343
|
const capabilityAvailability = await platformCapabilityAvailability(outcome, platform);
|
|
@@ -416,6 +428,22 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
416
428
|
"The gate still blocks until every id above is answered; pass --allow-unresolved-intake only for retrospective/harness initialization.",
|
|
417
429
|
};
|
|
418
430
|
}
|
|
431
|
+
const previousCompliance = await readJsonIfExists(compliancePath(repoRoot));
|
|
432
|
+
let engagementSwitch;
|
|
433
|
+
if (previousCompliance && previousCompliance.outcome !== outcome) {
|
|
434
|
+
const finalCheck = await checkCompliance(repoPath).catch(() => undefined);
|
|
435
|
+
if (finalCheck?.status === "green" && !options.supersedeEngagement) {
|
|
436
|
+
return {
|
|
437
|
+
status: "needs-engagement-supersede-confirmation",
|
|
438
|
+
exitCode: 7,
|
|
439
|
+
from: previousCompliance.outcome,
|
|
440
|
+
to: outcome,
|
|
441
|
+
reason: `The current engagement (outcome "${previousCompliance.outcome}") is green — finished, verified work. Initializing "${outcome}" replaces its contract, and its completeness will no longer be checked (it moves to the engagement ledger; see \`vise status\` previousEngagements and \`vise check --all-engagements\`).`,
|
|
442
|
+
instruction: `Re-run with --supersede-engagement to proceed. The green final state will be recorded in sp-vise/engagements/${previousCompliance.outcome}/ first.`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
engagementSwitch = await recordEngagementOnSwitch(repoRoot, previousCompliance, outcome, finalCheck);
|
|
446
|
+
}
|
|
419
447
|
const compliance = {
|
|
420
448
|
schema_version: schemaVersion,
|
|
421
449
|
vise_version: packageVersion,
|
|
@@ -468,6 +496,17 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
468
496
|
if (engagement && engagement.scope.outcomes.length > 0 && !engagement.scope.outcomes.includes(outcome)) {
|
|
469
497
|
warnings.push(`Outcome "${outcome}" is not in the engagement scope (${engagement.scope.outcomes.join(", ")}). Compliance was still initialized; extend the scope in engagement.json or re-run vise engagement init.`);
|
|
470
498
|
}
|
|
499
|
+
const preExistingFindingFiles = [
|
|
500
|
+
...new Set(checkSnapshot.rules
|
|
501
|
+
.filter((rule) => BASELINE_NON_GREEN.has(rule.status) && rule.finding?.file)
|
|
502
|
+
.map((rule) => rule.finding?.file)),
|
|
503
|
+
];
|
|
504
|
+
const brownfieldHint = !options.baselinePlanned && preExistingFindingFiles.length > 0 && !(await readBaseline(repoRoot))
|
|
505
|
+
? {
|
|
506
|
+
pre_existing_finding_files: preExistingFindingFiles.slice(0, 5),
|
|
507
|
+
hint: "This repo already contains social.plus integration with non-green findings that predate this engagement. Record `vise baseline .` NOW (before writing any code) so `vise check --new-only` gates only findings you introduce. The outcome's completeness checklist is never baselined — the requested feature still has to be built. Never record a baseline mid-build: it would swallow your own findings.",
|
|
508
|
+
}
|
|
509
|
+
: undefined;
|
|
471
510
|
return {
|
|
472
511
|
status: "initialized",
|
|
473
512
|
sidecar: complianceDirName,
|
|
@@ -480,6 +519,8 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
|
|
|
480
519
|
...(compliance.design_contract && { design_contract: compliance.design_contract }),
|
|
481
520
|
...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
|
|
482
521
|
...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
|
|
522
|
+
...(brownfieldHint ? { brownfield: brownfieldHint } : {}),
|
|
523
|
+
...(engagementSwitch ? { engagement_switch: engagementSwitch } : {}),
|
|
483
524
|
intake: {
|
|
484
525
|
status: intake.status,
|
|
485
526
|
remainingBlocking: intake.remainingBlocking,
|
|
@@ -862,7 +903,7 @@ async function ruleFreshness(rule) {
|
|
|
862
903
|
}
|
|
863
904
|
export async function checkCompliance(repoPath, options = {}) {
|
|
864
905
|
const repoRoot = path.resolve(repoPath);
|
|
865
|
-
const compliance = await readCompliance(repoRoot);
|
|
906
|
+
const compliance = options.contract ?? (await readCompliance(repoRoot));
|
|
866
907
|
const rules = await rulesById();
|
|
867
908
|
const drift = contractDrift(compliance, rules);
|
|
868
909
|
if (drift.length > 0) {
|
|
@@ -902,8 +943,23 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
902
943
|
}
|
|
903
944
|
}
|
|
904
945
|
const { canonicalPlatform } = await import("./sdkFacts.js");
|
|
905
|
-
const
|
|
906
|
-
const findings =
|
|
946
|
+
const validations = await Promise.all(platforms.map((p) => validateSetupWithCoverage(inspection.effectiveRoot, p)));
|
|
947
|
+
const findings = validations.flatMap((validation) => validation.findings);
|
|
948
|
+
const scanCoverage = {
|
|
949
|
+
complete: inspection.scanCoverage.complete && validations.every((validation) => validation.scanCoverage.complete),
|
|
950
|
+
scans: [...inspection.scanCoverage.scans, ...validations.flatMap((validation) => validation.scanCoverage.scans)],
|
|
951
|
+
};
|
|
952
|
+
if (!inspection.scanCoverage.complete && !findings.some((finding) => finding.ruleId === "project.scan.complete")) {
|
|
953
|
+
const truncated = inspection.scanCoverage.scans.filter((scan) => scan.truncated);
|
|
954
|
+
findings.push({
|
|
955
|
+
ruleId: "project.scan.complete",
|
|
956
|
+
severity: "error",
|
|
957
|
+
message: `Vise could not inspect the complete project inventory: ${truncated
|
|
958
|
+
.map((scan) => `${scan.scope} reached its ${scan.limit}-file limit under ${scan.root}`)
|
|
959
|
+
.join("; ")}.`,
|
|
960
|
+
recommendation: "Point Vise at the specific app with --surface/--surface-path and re-run init/check.",
|
|
961
|
+
});
|
|
962
|
+
}
|
|
907
963
|
const findingsById = new Map();
|
|
908
964
|
const findingsByIdAll = new Map();
|
|
909
965
|
for (const finding of findings) {
|
|
@@ -915,7 +971,7 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
915
971
|
}
|
|
916
972
|
}
|
|
917
973
|
const attestations = await readAttestations(repoRoot);
|
|
918
|
-
const attestationHygiene = await readAttestationHygiene(repoRoot, compliance);
|
|
974
|
+
const attestationHygiene = await readAttestationHygiene(repoRoot, inspection.effectiveRoot, compliance);
|
|
919
975
|
const results = [];
|
|
920
976
|
for (const ref of compliance.rules) {
|
|
921
977
|
const rule = rules.get(ref.rule_id);
|
|
@@ -996,6 +1052,27 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
996
1052
|
const exactMatch = attestation.rule_digest === ref.rule_digest;
|
|
997
1053
|
const grandfathered = !exactMatch && isAttestationGrandfathered(rule, attestation);
|
|
998
1054
|
if (exactMatch || grandfathered) {
|
|
1055
|
+
if (attestation.outcome !== undefined &&
|
|
1056
|
+
attestation.outcome !== compliance.outcome &&
|
|
1057
|
+
!(attestation.file_scoped === true && isFileScopableRule(rule))) {
|
|
1058
|
+
const crossEngagementStatus = rule.advisory ? "advisory" : rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail";
|
|
1059
|
+
results.push({
|
|
1060
|
+
...checkRuleIdentity(rule.id),
|
|
1061
|
+
title: rule.title,
|
|
1062
|
+
severity: rule.severity,
|
|
1063
|
+
status: crossEngagementStatus,
|
|
1064
|
+
reason: rule.advisory
|
|
1065
|
+
? "Advisory: informational only — does not affect compliance status."
|
|
1066
|
+
: `Attestation was recorded during a different engagement (outcome "${attestation.outcome}", attested ${attestation.attested_at}); the current engagement is "${compliance.outcome}". Re-verify the claim still holds for the code this engagement adds, then re-attest.`,
|
|
1067
|
+
finding,
|
|
1068
|
+
recommendation: finding?.recommendation,
|
|
1069
|
+
rationale: rule.rationale,
|
|
1070
|
+
current_rule: ruleSummary(rule),
|
|
1071
|
+
stale_engagement_attestation: { outcome: attestation.outcome, attested_at: attestation.attested_at },
|
|
1072
|
+
...(crossEngagementStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
1073
|
+
});
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
999
1076
|
const sourceFingerprintStatus = await checkSourceFingerprints(repoRoot, inspection.effectiveRoot, attestation.source_fingerprints ?? []);
|
|
1000
1077
|
const staleFingerprints = sourceFingerprintStatus.filter((item) => item.status !== "match");
|
|
1001
1078
|
if (staleFingerprints.length > 0) {
|
|
@@ -1088,6 +1165,18 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
1088
1165
|
...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
|
|
1089
1166
|
});
|
|
1090
1167
|
}
|
|
1168
|
+
const incompleteScanFinding = findings.find((finding) => finding.ruleId === "project.scan.complete");
|
|
1169
|
+
if (incompleteScanFinding) {
|
|
1170
|
+
results.push({
|
|
1171
|
+
ruleId: incompleteScanFinding.ruleId,
|
|
1172
|
+
title: "Complete source scan",
|
|
1173
|
+
severity: "error",
|
|
1174
|
+
status: "deterministic-fail",
|
|
1175
|
+
reason: incompleteScanFinding.message,
|
|
1176
|
+
finding: incompleteScanFinding,
|
|
1177
|
+
recommendation: incompleteScanFinding.recommendation,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1091
1180
|
const driftStale = await surfaceDriftStale(compliance);
|
|
1092
1181
|
if (driftStale.size > 0) {
|
|
1093
1182
|
for (const result of results) {
|
|
@@ -1153,22 +1242,23 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
1153
1242
|
result.baselined = true;
|
|
1154
1243
|
}
|
|
1155
1244
|
}
|
|
1156
|
-
const
|
|
1157
|
-
const baselinedSelected = new Set(baselineFile.selected_optional);
|
|
1245
|
+
const legacyDeliverableEntries = (baselineFile.completeness_missing?.length ?? 0) + (baselineFile.selected_optional?.length ?? 0);
|
|
1158
1246
|
gatedResults = results.filter((result) => !result.baselined);
|
|
1159
|
-
gatedMissing = (completeness?.missing ?? []).filter((item) => !baselinedMissing.has(item.id));
|
|
1160
|
-
gatedSelectedFailed = (selectedOptionalCapabilities?.failed ?? []).filter((item) => !baselinedSelected.has(item.id));
|
|
1161
|
-
gatedSelectedUnknown = (selectedOptionalCapabilities?.unknown ?? []).filter((id) => !baselinedSelected.has(id));
|
|
1162
1247
|
baselineReport = {
|
|
1163
1248
|
present: true,
|
|
1164
1249
|
applied: true,
|
|
1165
1250
|
baselined_at: baselineFile.baselined_at,
|
|
1166
1251
|
excluded: {
|
|
1167
1252
|
rules: results.filter((result) => result.baselined).length,
|
|
1168
|
-
completeness:
|
|
1169
|
-
selected_optional:
|
|
1170
|
-
((selectedOptionalCapabilities?.unknown.length ?? 0) - gatedSelectedUnknown.length),
|
|
1253
|
+
completeness: 0,
|
|
1254
|
+
selected_optional: 0,
|
|
1171
1255
|
},
|
|
1256
|
+
...(legacyDeliverableEntries > 0
|
|
1257
|
+
? {
|
|
1258
|
+
ignored_legacy_deliverable_entries: legacyDeliverableEntries,
|
|
1259
|
+
ignored_legacy_note: "This baseline predates the deliverables fix and recorded completeness/selected-capability entries; they are ignored — the engagement's deliverables always gate. Re-run `vise baseline` to refresh the file.",
|
|
1260
|
+
}
|
|
1261
|
+
: {}),
|
|
1172
1262
|
residual_caveat: "Baseline excludes pre-existing findings keyed by rule+file (count-based). A NEW violation of an already-baselined rule in the SAME file can be masked at this granularity — review the touched files directly for a precise per-change gate.",
|
|
1173
1263
|
};
|
|
1174
1264
|
}
|
|
@@ -1181,7 +1271,7 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
1181
1271
|
const { status, exitCode } = deriveGate({
|
|
1182
1272
|
hasNoPlatform: detectedPlatforms.length === 0,
|
|
1183
1273
|
hasBlocked: gatedResults.some((result) => result.status === "blocked"),
|
|
1184
|
-
hasDeterministicFailure: gatedResults.some((result) => result.status === "deterministic-fail"),
|
|
1274
|
+
hasDeterministicFailure: !scanCoverage.complete || gatedResults.some((result) => result.status === "deterministic-fail"),
|
|
1185
1275
|
needsAttestation: gatedResults.some((result) => result.status === "attestation-needed" || result.status === "stale"),
|
|
1186
1276
|
hasCompletenessGap,
|
|
1187
1277
|
hasSelectedOptionalFailures,
|
|
@@ -1213,6 +1303,7 @@ export async function checkCompliance(repoPath, options = {}) {
|
|
|
1213
1303
|
surfacePath: compliance.surface?.path,
|
|
1214
1304
|
summary,
|
|
1215
1305
|
rules: results,
|
|
1306
|
+
scanCoverage,
|
|
1216
1307
|
...(evidenceBasis ? { evidence_basis: evidenceBasis } : {}),
|
|
1217
1308
|
...(evidenceBasisNote ? { evidence_basis_note: evidenceBasisNote } : {}),
|
|
1218
1309
|
...(completeness && (completeness.missing.length > 0 || completeness.optedOut.length > 0 || completeness.present.length > 0) ? { completeness } : {}),
|
|
@@ -1668,6 +1759,29 @@ export async function statusCompliance(repoPath, options = {}) {
|
|
|
1668
1759
|
reviewer_assignment: engagement.reviewer_assignment,
|
|
1669
1760
|
},
|
|
1670
1761
|
}),
|
|
1762
|
+
...(await previousEngagementsSection(repoRoot, check.outcome)),
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
async function previousEngagementsSection(repoRoot, activeOutcome) {
|
|
1766
|
+
const dir = engagementsDir(repoRoot);
|
|
1767
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1768
|
+
const previous = [];
|
|
1769
|
+
for (const entry of entries) {
|
|
1770
|
+
if (!entry.isDirectory() || entry.name === activeOutcome) {
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
const record = await readJsonIfExists(path.join(dir, entry.name, "record.json"));
|
|
1774
|
+
if (record) {
|
|
1775
|
+
previous.push(record);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (previous.length === 0) {
|
|
1779
|
+
return {};
|
|
1780
|
+
}
|
|
1781
|
+
previous.sort((a, b) => String(a.outcome).localeCompare(String(b.outcome)));
|
|
1782
|
+
return {
|
|
1783
|
+
previousEngagements: previous,
|
|
1784
|
+
previousEngagements_note: "Superseded engagements' last recorded state (latest per outcome; snapshots, not live verdicts). Their completeness is NOT checked by the active contract — to re-verify one today, re-run `vise init` with its original request and `vise check`. On-demand re-verification lands with `vise check --all-engagements` (docs/ENGAGEMENTS_DESIGN.md, B2).",
|
|
1671
1785
|
};
|
|
1672
1786
|
}
|
|
1673
1787
|
async function applicableRules(outcome, platforms) {
|
|
@@ -2253,7 +2367,8 @@ async function liveSensorContradiction(repoRoot, compliance, rule) {
|
|
|
2253
2367
|
if (platforms.length === 0) {
|
|
2254
2368
|
return { violatorFiles: [] };
|
|
2255
2369
|
}
|
|
2256
|
-
const
|
|
2370
|
+
const validations = await Promise.all(platforms.map((platform) => validateSetupWithCoverage(inspection.effectiveRoot, platform)));
|
|
2371
|
+
const allFindings = validations.map((validation) => validation.findings);
|
|
2257
2372
|
const findingsById = new Map();
|
|
2258
2373
|
const findingsByIdAll = new Map();
|
|
2259
2374
|
for (const finding of allFindings.flat()) {
|
|
@@ -2266,6 +2381,19 @@ async function liveSensorContradiction(repoRoot, compliance, rule) {
|
|
|
2266
2381
|
}
|
|
2267
2382
|
const finding = deterministicFinding(rule, findingsById);
|
|
2268
2383
|
if (!finding) {
|
|
2384
|
+
const incomplete = allFindings.flat().find((item) => item.ruleId === "project.scan.complete");
|
|
2385
|
+
if (incomplete) {
|
|
2386
|
+
return {
|
|
2387
|
+
warning: {
|
|
2388
|
+
message: "The live deterministic sensor could not inspect the complete source surface. This attestation overrides an incomplete analysis; narrow the surface and re-check before relying on it.",
|
|
2389
|
+
sensor_finding: {
|
|
2390
|
+
ruleId: incomplete.ruleId,
|
|
2391
|
+
recommendation: incomplete.recommendation,
|
|
2392
|
+
},
|
|
2393
|
+
},
|
|
2394
|
+
violatorFiles: [],
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2269
2397
|
return { violatorFiles: [] };
|
|
2270
2398
|
}
|
|
2271
2399
|
const violatorFiles = await ruleViolatorFiles(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll);
|
|
@@ -2435,6 +2563,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
|
|
|
2435
2563
|
evidence,
|
|
2436
2564
|
source_fingerprints: sourceFingerprints,
|
|
2437
2565
|
...(fileScoped ? { file_scoped: true } : {}),
|
|
2566
|
+
outcome: compliance.outcome,
|
|
2438
2567
|
rationale_for_attestation: rationale,
|
|
2439
2568
|
attested_at: new Date().toISOString(),
|
|
2440
2569
|
};
|
|
@@ -2685,9 +2814,24 @@ async function readAttestations(repoRoot) {
|
|
|
2685
2814
|
}
|
|
2686
2815
|
return result;
|
|
2687
2816
|
}
|
|
2688
|
-
async function
|
|
2817
|
+
async function recordedEngagementRuleIds(repoRoot) {
|
|
2818
|
+
const ruleIds = new Set();
|
|
2819
|
+
const entries = await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []);
|
|
2820
|
+
for (const entry of entries) {
|
|
2821
|
+
if (!entry.isDirectory()) {
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2824
|
+
const contract = await readJsonIfExists(path.join(engagementsDir(repoRoot), entry.name, "contract.json"));
|
|
2825
|
+
for (const rule of contract?.rules ?? []) {
|
|
2826
|
+
ruleIds.add(rule.rule_id);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
return ruleIds;
|
|
2830
|
+
}
|
|
2831
|
+
async function readAttestationHygiene(repoRoot, sourceRoot, compliance) {
|
|
2689
2832
|
const dir = attestationsDir(repoRoot);
|
|
2690
2833
|
const activeRuleIds = new Set(compliance.rules.map((rule) => rule.rule_id));
|
|
2834
|
+
const recordedRuleIds = await recordedEngagementRuleIds(repoRoot);
|
|
2691
2835
|
const ignored = [];
|
|
2692
2836
|
const invalid = [];
|
|
2693
2837
|
let entries = [];
|
|
@@ -2708,10 +2852,24 @@ async function readAttestationHygiene(repoRoot, compliance) {
|
|
|
2708
2852
|
continue;
|
|
2709
2853
|
}
|
|
2710
2854
|
if (!activeRuleIds.has(candidate.attestation.rule_id)) {
|
|
2855
|
+
const sourceFingerprintStatus = await checkSourceFingerprints(repoRoot, sourceRoot, candidate.attestation.source_fingerprints ?? []);
|
|
2856
|
+
const staleFingerprints = sourceFingerprintStatus.filter((item) => item.status !== "match");
|
|
2857
|
+
if (staleFingerprints.length > 0) {
|
|
2858
|
+
ignored.push({
|
|
2859
|
+
file: rel,
|
|
2860
|
+
rule_id: candidate.attestation.rule_id,
|
|
2861
|
+
reason: "Attestation is outside the active contract and its recorded source fingerprints changed or disappeared. Refresh or remove it before relying on the evidence trail.",
|
|
2862
|
+
source_fingerprint_status: sourceFingerprintStatus,
|
|
2863
|
+
});
|
|
2864
|
+
continue;
|
|
2865
|
+
}
|
|
2866
|
+
if (recordedRuleIds.has(candidate.attestation.rule_id)) {
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2711
2869
|
ignored.push({
|
|
2712
2870
|
file: rel,
|
|
2713
2871
|
rule_id: candidate.attestation.rule_id,
|
|
2714
|
-
reason: "Attestation rule_id is not in the
|
|
2872
|
+
reason: "Attestation rule_id is not in the active or recorded engagement contracts. It may be stale or off-platform and is ignored by vise check.",
|
|
2715
2873
|
});
|
|
2716
2874
|
}
|
|
2717
2875
|
}
|
|
@@ -2722,7 +2880,7 @@ async function readAttestationHygiene(repoRoot, compliance) {
|
|
|
2722
2880
|
status: "needs-review",
|
|
2723
2881
|
ignored,
|
|
2724
2882
|
invalid,
|
|
2725
|
-
nextStep: "Review
|
|
2883
|
+
nextStep: "Review fingerprint-stale, orphaned, or invalid attestation files before handoff. Matching evidence retained by a recorded engagement is checked through the engagement ledger and is not reported as stale.",
|
|
2726
2884
|
};
|
|
2727
2885
|
}
|
|
2728
2886
|
async function readAttestationCandidate(filePath) {
|
|
@@ -2791,6 +2949,8 @@ function sidecarReadme(compliance) {
|
|
|
2791
2949
|
"",
|
|
2792
2950
|
"Attestations include source fingerprints; `vise check` marks them stale if the cited files change.",
|
|
2793
2951
|
"",
|
|
2952
|
+
"`engagements/<outcome>/` (when present) is the engagement ledger: each superseded engagement's final check, contract, and intake, recorded when a `vise init` switched to a different outcome. `vise status` lists them under `previousEngagements`; re-verify with `vise check --engagement <outcome>` or `--all-engagements`.",
|
|
2953
|
+
"",
|
|
2794
2954
|
].join("\n");
|
|
2795
2955
|
}
|
|
2796
2956
|
function attestationsDir(repoRoot) {
|
|
@@ -2827,6 +2987,129 @@ function deriveGate(flags) {
|
|
|
2827
2987
|
return { status: "runtime-proof-waived", exitCode: flags.allowProofWaiver ? 0 : 9 };
|
|
2828
2988
|
return { status: "green", exitCode: 0 };
|
|
2829
2989
|
}
|
|
2990
|
+
async function recordEngagementOnSwitch(repoRoot, previous, nextOutcome, finalCheck) {
|
|
2991
|
+
const previousIntake = await readJsonIfExists(path.join(sidecarDir(repoRoot), "intake.json"));
|
|
2992
|
+
const dir = path.join(engagementsDir(repoRoot), String(previous.outcome));
|
|
2993
|
+
await rm(dir, { recursive: true, force: true });
|
|
2994
|
+
await mkdir(dir, { recursive: true });
|
|
2995
|
+
const verdict = finalCheck
|
|
2996
|
+
? { status: finalCheck.status, exitCode: finalCheck.exitCode }
|
|
2997
|
+
: { status: "unrecorded", exitCode: null, note: "The final check could not run at switch time; contract and intake are still recorded." };
|
|
2998
|
+
const record = {
|
|
2999
|
+
outcome: previous.outcome,
|
|
3000
|
+
...(typeof previousIntake?.request === "string" ? { request: previousIntake.request } : {}),
|
|
3001
|
+
recorded_at: new Date().toISOString(),
|
|
3002
|
+
reason: `superseded by ${nextOutcome} init`,
|
|
3003
|
+
ruleset_digest: previous.ruleset_digest,
|
|
3004
|
+
verdict,
|
|
3005
|
+
};
|
|
3006
|
+
await writeJson(path.join(dir, "record.json"), record);
|
|
3007
|
+
if (finalCheck) {
|
|
3008
|
+
await writeJson(path.join(dir, "final-check.json"), finalCheck);
|
|
3009
|
+
}
|
|
3010
|
+
await writeJson(path.join(dir, "contract.json"), previous);
|
|
3011
|
+
if (previousIntake) {
|
|
3012
|
+
await writeJson(path.join(dir, "intake.json"), previousIntake);
|
|
3013
|
+
}
|
|
3014
|
+
return {
|
|
3015
|
+
from: previous.outcome,
|
|
3016
|
+
to: nextOutcome,
|
|
3017
|
+
recorded: path.join(complianceDirName, "engagements", String(previous.outcome)),
|
|
3018
|
+
last_verdict: verdict,
|
|
3019
|
+
note: "The previous engagement's final state is recorded in the ledger; its completeness is no longer checked by this contract. `vise status` lists it under previousEngagements. On-demand re-verification (`vise check --all-engagements`) lands with B2 (docs/ENGAGEMENTS_DESIGN.md).",
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
function engagementsDir(repoRoot) {
|
|
3023
|
+
return path.join(sidecarDir(repoRoot), "engagements");
|
|
3024
|
+
}
|
|
3025
|
+
async function reVerdictOne(repoPath, repoRoot, outcome) {
|
|
3026
|
+
const dir = path.join(engagementsDir(repoRoot), outcome);
|
|
3027
|
+
const record = await readJsonIfExists(path.join(dir, "record.json"));
|
|
3028
|
+
const recorded = await readJsonIfExists(path.join(dir, "contract.json"));
|
|
3029
|
+
if (!record || !recorded) {
|
|
3030
|
+
const available = (await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []))
|
|
3031
|
+
.filter((entry) => entry.isDirectory())
|
|
3032
|
+
.map((entry) => entry.name);
|
|
3033
|
+
throw new Error(`No recorded engagement for outcome "${outcome}".${available.length > 0 ? ` Recorded engagements: ${available.join(", ")}.` : " The engagement ledger is empty (it is written when an init switches away from an outcome)."}`);
|
|
3034
|
+
}
|
|
3035
|
+
const inspection = await inspectProject(repoRoot, recorded.surface?.path === "." ? undefined : recorded.surface?.path);
|
|
3036
|
+
const platforms = Array.from(new Set([...inspection.platforms, ...(recorded.surface?.platforms ?? [])]));
|
|
3037
|
+
const rules = await applicableRules(recorded.outcome, platforms.length > 0 ? platforms : ["unknown"]);
|
|
3038
|
+
const synthetic = {
|
|
3039
|
+
...recorded,
|
|
3040
|
+
ruleset_digest: digestJson(rules.map(ruleRef)),
|
|
3041
|
+
rules: rules.map(ruleRefForFile),
|
|
3042
|
+
generated_at: new Date().toISOString(),
|
|
3043
|
+
};
|
|
3044
|
+
const check = await checkCompliance(repoPath, { contract: synthetic });
|
|
3045
|
+
return { record, check, ruleset_upgraded: recorded.ruleset_digest !== synthetic.ruleset_digest };
|
|
3046
|
+
}
|
|
3047
|
+
export async function checkEngagementReVerdict(repoPath, outcome) {
|
|
3048
|
+
const repoRoot = path.resolve(repoPath);
|
|
3049
|
+
const { record, check, ruleset_upgraded } = await reVerdictOne(repoPath, repoRoot, outcome);
|
|
3050
|
+
const drifted = check.status !== "green";
|
|
3051
|
+
return {
|
|
3052
|
+
mode: "re-verdict",
|
|
3053
|
+
engagement: outcome,
|
|
3054
|
+
...(typeof record.request === "string" ? { request: record.request } : {}),
|
|
3055
|
+
recorded_at: record.recorded_at,
|
|
3056
|
+
recorded_verdict: record.verdict,
|
|
3057
|
+
ruleset_upgraded,
|
|
3058
|
+
status: check.status,
|
|
3059
|
+
exitCode: drifted ? 11 : 0,
|
|
3060
|
+
summary: check.summary,
|
|
3061
|
+
...(check.completeness ? { completeness: check.completeness } : {}),
|
|
3062
|
+
rules: check.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status)),
|
|
3063
|
+
note: "Re-verdict of a recorded engagement against the current ruleset and code. It does not change the active engagement's gate; exit 11 signals engagement drift.",
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
export async function checkAllEngagements(repoPath, options = {}) {
|
|
3067
|
+
const repoRoot = path.resolve(repoPath);
|
|
3068
|
+
const active = await checkCompliance(repoPath, options);
|
|
3069
|
+
const activeNonGreenKeys = new Set(active.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status)).map((rule) => baselineKeyFor(rule)));
|
|
3070
|
+
const entries = await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []);
|
|
3071
|
+
const engagements = [];
|
|
3072
|
+
let anyDrift = false;
|
|
3073
|
+
for (const entry of entries) {
|
|
3074
|
+
if (!entry.isDirectory() || entry.name === active.outcome) {
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
const { record, check, ruleset_upgraded } = await reVerdictOne(repoPath, repoRoot, entry.name);
|
|
3078
|
+
const nonGreen = check.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status));
|
|
3079
|
+
const gating = nonGreen.filter((rule) => !activeNonGreenKeys.has(baselineKeyFor(rule)));
|
|
3080
|
+
const shared = nonGreen.length - gating.length;
|
|
3081
|
+
const completenessMissing = check.completeness?.missing ?? [];
|
|
3082
|
+
const drifted = gating.length > 0 || completenessMissing.length > 0 || check.status === "contract-drift";
|
|
3083
|
+
anyDrift = anyDrift || drifted;
|
|
3084
|
+
engagements.push({
|
|
3085
|
+
outcome: entry.name,
|
|
3086
|
+
...(typeof record.request === "string" ? { request: record.request } : {}),
|
|
3087
|
+
recorded_at: record.recorded_at,
|
|
3088
|
+
recorded_verdict: record.verdict,
|
|
3089
|
+
ruleset_upgraded,
|
|
3090
|
+
status: drifted ? check.status : "green",
|
|
3091
|
+
drift: drifted,
|
|
3092
|
+
...(shared > 0 ? { shared_with_active: shared } : {}),
|
|
3093
|
+
...(gating.length > 0 ? { gating } : {}),
|
|
3094
|
+
...(completenessMissing.length > 0 ? { completeness_missing: completenessMissing.map((item) => item.id) } : {}),
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
engagements.sort((a, b) => String(a.outcome).localeCompare(String(b.outcome)));
|
|
3098
|
+
const activeGreen = active.exitCode === 0;
|
|
3099
|
+
return {
|
|
3100
|
+
...active,
|
|
3101
|
+
...(activeGreen && anyDrift
|
|
3102
|
+
? {
|
|
3103
|
+
status: "engagement-drift",
|
|
3104
|
+
exitCode: 11,
|
|
3105
|
+
active_status: active.status,
|
|
3106
|
+
active_exitCode: active.exitCode,
|
|
3107
|
+
}
|
|
3108
|
+
: {}),
|
|
3109
|
+
engagements,
|
|
3110
|
+
engagements_note: "Re-verdicts of recorded engagements against the current ruleset and code. Findings already gating the active engagement are counted once there (shared_with_active). Exit 11 = active engagement green but a recorded engagement drifted.",
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
2830
3113
|
export async function recordBaseline(repoPath) {
|
|
2831
3114
|
const repoRoot = path.resolve(repoPath);
|
|
2832
3115
|
const compliance = await readCompliance(repoRoot);
|
|
@@ -2841,19 +3124,18 @@ export async function recordBaseline(repoPath) {
|
|
|
2841
3124
|
const key = baselineKeyFor(result);
|
|
2842
3125
|
findings[key] = (findings[key] ?? 0) + 1;
|
|
2843
3126
|
}
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
];
|
|
3127
|
+
const deliverables_not_baselined = {
|
|
3128
|
+
completeness_missing: (check.completeness?.missing ?? []).length,
|
|
3129
|
+
selected_optional: (check.selectedOptionalCapabilities?.failed ?? []).length + (check.selectedOptionalCapabilities?.unknown ?? []).length,
|
|
3130
|
+
};
|
|
2849
3131
|
const baseline = {
|
|
2850
3132
|
baselined_at: new Date().toISOString(),
|
|
2851
3133
|
vise_version: packageVersion,
|
|
2852
3134
|
outcome: compliance.outcome,
|
|
2853
3135
|
ruleset_digest: compliance.ruleset_digest,
|
|
2854
3136
|
findings,
|
|
2855
|
-
completeness_missing,
|
|
2856
|
-
selected_optional,
|
|
3137
|
+
completeness_missing: [],
|
|
3138
|
+
selected_optional: [],
|
|
2857
3139
|
};
|
|
2858
3140
|
await writeJson(baselinePath(repoRoot), baseline);
|
|
2859
3141
|
return {
|
|
@@ -2862,9 +3144,15 @@ export async function recordBaseline(repoPath) {
|
|
|
2862
3144
|
outcome: baseline.outcome,
|
|
2863
3145
|
recorded: {
|
|
2864
3146
|
findings: Object.values(findings).reduce((a, b) => a + b, 0),
|
|
2865
|
-
completeness_missing: completeness_missing.length,
|
|
2866
|
-
selected_optional: selected_optional.length,
|
|
2867
3147
|
},
|
|
3148
|
+
...(deliverables_not_baselined.completeness_missing + deliverables_not_baselined.selected_optional > 0
|
|
3149
|
+
? {
|
|
3150
|
+
deliverables_not_baselined: {
|
|
3151
|
+
...deliverables_not_baselined,
|
|
3152
|
+
note: "The outcome's completeness checklist and selected optional capabilities are this engagement's deliverables — they are never baselined and still gate `vise check --new-only`. Use a completeness opt-out for capabilities the product genuinely does not want.",
|
|
3153
|
+
},
|
|
3154
|
+
}
|
|
3155
|
+
: {}),
|
|
2868
3156
|
nextStep: "Implement your change, then run `vise check --new-only` to gate only on findings introduced since this baseline. Re-run `vise baseline` after the contract or outcome changes.",
|
|
2869
3157
|
};
|
|
2870
3158
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const INTERNAL_PACKAGE_NAMES = {
|
|
4
|
+
"@amityco/social-plus-vise": "the Vise source tree",
|
|
5
|
+
"social-plus-blocks": "the social.plus blocks product tree",
|
|
6
|
+
};
|
|
7
|
+
export async function detectInternalWorkspace(repoPath) {
|
|
8
|
+
const root = path.resolve(repoPath);
|
|
9
|
+
const markers = [];
|
|
10
|
+
const rootName = await packageName(root);
|
|
11
|
+
if (rootName && INTERNAL_PACKAGE_NAMES[rootName]) {
|
|
12
|
+
markers.push(`package.json name "${rootName}" — this is ${INTERNAL_PACKAGE_NAMES[rootName]}`);
|
|
13
|
+
}
|
|
14
|
+
const workspaceFile = path.join(root, "pnpm-workspace.yaml");
|
|
15
|
+
if (await readFile(workspaceFile, "utf8").then(() => true, () => false)) {
|
|
16
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const memberName = await packageName(path.join(root, entry.name));
|
|
22
|
+
if (memberName && INTERNAL_PACKAGE_NAMES[memberName]) {
|
|
23
|
+
markers.push(`workspace member ${entry.name}/ is "${memberName}" — this is ${INTERNAL_PACKAGE_NAMES[memberName]}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { isInternalWorkspace: markers.length > 0, markers };
|
|
28
|
+
}
|
|
29
|
+
export function internalWorkspaceWarning(detection) {
|
|
30
|
+
return {
|
|
31
|
+
detectedMarkers: detection.markers,
|
|
32
|
+
reason: "This target looks like the social.plus Vise / social.plus blocks development workspace, not a customer project.",
|
|
33
|
+
guidance: "Customer-integration commands write sp-vise/ state into a customer repo. For SDK surface facts while packaging blocks, use `vise sdk-facts` (projectless and read-only). To run the customer flow against a workspace fixture, point Vise at the fixture directory itself.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function packageName(directory) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
|
|
39
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|