@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.
@@ -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, validateSetup } from "./project.js";
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 allFindings = await Promise.all(platforms.map((p) => validateSetup(inspection.effectiveRoot, p)));
906
- const findings = allFindings.flat();
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 baselinedMissing = new Set(baselineFile.completeness_missing);
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: (completeness?.missing.length ?? 0) - gatedMissing.length,
1169
- selected_optional: ((selectedOptionalCapabilities?.failed.length ?? 0) - gatedSelectedFailed.length) +
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 allFindings = await Promise.all(platforms.map((platform) => validateSetup(inspection.effectiveRoot, platform)));
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 readAttestationHygiene(repoRoot, compliance) {
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 current compliance contract. It may be stale, off-platform, or from a previous sidecar scope; it is ignored by vise check.",
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 ignored/invalid attestation files before using the evidence trail for handoff. Remove stale files only when you are sure they are unrelated, or re-run vise init/check/sync for the intended scope.",
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 completeness_missing = (check.completeness?.missing ?? []).map((item) => item.id);
2845
- const selected_optional = [
2846
- ...(check.selectedOptionalCapabilities?.failed ?? []).map((item) => item.id),
2847
- ...(check.selectedOptionalCapabilities?.unknown ?? []),
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
+ }