@amityco/social-plus-vise 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +75 -2
  2. package/README.md +23 -10
  3. package/dist/capabilities.js +35 -4
  4. package/dist/entryState.js +71 -0
  5. package/dist/experience.js +70 -0
  6. package/dist/explore.js +51 -0
  7. package/dist/flow.js +382 -0
  8. package/dist/humanFormat.js +251 -0
  9. package/dist/intake.js +117 -0
  10. package/dist/intelligence/placement.js +2 -1
  11. package/dist/outcomes.js +141 -42
  12. package/dist/productExpectations.js +15 -0
  13. package/dist/requestReadiness.js +99 -0
  14. package/dist/server.js +675 -56
  15. package/dist/sidecar.js +5 -0
  16. package/dist/solutionPath.js +2 -2
  17. package/dist/tools/compliance.js +1014 -133
  18. package/dist/tools/creative.js +14 -12
  19. package/dist/tools/debug.js +83 -26
  20. package/dist/tools/design.js +344 -14
  21. package/dist/tools/experienceCompiler.js +1 -1
  22. package/dist/tools/experienceSensors.js +1 -1
  23. package/dist/tools/harness.js +13 -0
  24. package/dist/tools/integration.js +263 -90
  25. package/dist/tools/learning.js +1 -3
  26. package/dist/tools/project.js +987 -72
  27. package/dist/tools/sdkFacts.js +8 -1
  28. package/dist/tools/smoke.js +134 -0
  29. package/dist/tools/uxHarness.js +54 -6
  30. package/dist/uikitCustomization.js +22 -6
  31. package/dist/version.js +7 -3
  32. package/package.json +1 -1
  33. package/packages/intelligence/catalog/catalog.schema.json +1 -1
  34. package/packages/intelligence/catalog/experience-objects.json +2 -2
  35. package/packages/intelligence/catalog/variants.json +24 -0
  36. package/rules/event.yaml +3 -0
  37. package/rules/feed.yaml +251 -0
  38. package/rules/invitation.yaml +4 -0
  39. package/rules/live-data.yaml +110 -0
  40. package/rules/notification-tray.yaml +4 -0
  41. package/rules/poll.yaml +5 -0
  42. package/rules/sdk-lifecycle.yaml +559 -0
  43. package/rules/search.yaml +5 -0
  44. package/rules/security.yaml +12 -0
  45. package/rules/story.yaml +5 -0
  46. package/rules/user-blocking.yaml +5 -0
  47. package/skills/social-plus-vise/SKILL.md +163 -15
@@ -1,11 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
3
4
  import path from "node:path";
5
+ import { sidecarDir } from "../sidecar.js";
4
6
  import { fileURLToPath } from "node:url";
5
- import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, optionalCapabilityChecklist, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
6
- import { getOutcomeDefinition, hasAnswer, planContextFor, resolveOutcome, } from "../outcomes.js";
7
+ import { applyBlockProvidedCompleteness, assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
8
+ import { CLASSIFY_ORDER, getOutcomeDefinition, planContextFor, resolveOutcome, } from "../outcomes.js";
7
9
  import { contractRuleCandidatesForPublicId, hasMultipleContractRuleCandidates, productExpectationBindingForSensor, productExpectationTitle, publicProductRuleId, } from "../productExpectations.js";
8
10
  import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
11
+ import { assembleIntake } from "../intake.js";
12
+ import { clarifyDimensions, clarifyGuidance } from "../requestReadiness.js";
13
+ import { recommendSolutionPath } from "../solutionPath.js";
14
+ import { recommendUIKitCustomization } from "../uikitCustomization.js";
9
15
  import { packageVersion } from "../version.js";
10
16
  import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
11
17
  import { installedBlockProvidedCapabilities } from "./blocks.js";
@@ -210,7 +216,7 @@ function stringArray(value) {
210
216
  return value.filter((item) => typeof item === "string" && item.trim() !== "");
211
217
  }
212
218
  const validTiers = new Set(["free", "pro", "partner"]);
213
- const validOutcomes = new Set(["setup-sdk", "setup-push", "setup-live-data", "add-feed", "add-comments", "add-moderation", "add-chat", "troubleshoot", "validate-setup", "unknown"]);
219
+ const validOutcomes = new Set([...CLASSIFY_ORDER, "unknown"]);
214
220
  export async function initEngagement(args) {
215
221
  const repoRoot = path.resolve(args.repoPath);
216
222
  const engagementFile = engagementPath(repoRoot);
@@ -289,6 +295,13 @@ function designReviewFor(repoRoot, contract, answers) {
289
295
  requiredForDesignConformance: true,
290
296
  };
291
297
  if (confirmation === "accepted") {
298
+ if (base.previewPath && !existsSync(base.previewPath)) {
299
+ return {
300
+ ...base,
301
+ status: "needs-confirmation",
302
+ nextStep: `Cannot accept the design contract: its preview (${base.previewPath}) does not exist, so it could not have been reviewed. Run \`vise design preview\` to regenerate it, review it, then re-confirm with --answer ${DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID}=yes.`,
303
+ };
304
+ }
292
305
  return {
293
306
  ...base,
294
307
  status: "accepted",
@@ -327,6 +340,26 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
327
340
  const creativeSelection = storedCreativeSelection && creativeSelectionAppliesToRequest(storedCreativeSelection, request)
328
341
  ? storedCreativeSelection
329
342
  : undefined;
343
+ if (options.allowUnresolvedIntake !== true) {
344
+ const platformDetected = inspection.platforms.length > 0;
345
+ const initSolutionPath = recommendSolutionPath(request, answers);
346
+ const initUikit = recommendUIKitCustomization({ request, answers, platform, solutionPath: initSolutionPath });
347
+ const clarify = clarifyGuidance(clarifyDimensions({
348
+ outcome,
349
+ platformDetected,
350
+ uikitGuidedUnknown: platformDetected && outcome === "unknown" && Boolean(initUikit),
351
+ creativeSelected: Boolean(creativeSelection),
352
+ }));
353
+ if (clarify) {
354
+ return {
355
+ status: "needs-clarification",
356
+ exitCode: 7,
357
+ outcome,
358
+ clarify,
359
+ nextStep: `${clarify.nextStep} (Or pass --allow-unresolved-intake for retrospective/harness initialization.)`,
360
+ };
361
+ }
362
+ }
330
363
  const uxHarness = creativeSelection
331
364
  ? await buildUxHarness({ repoPath: repoRoot, surfacePath, selection: creativeSelection, write: true })
332
365
  : undefined;
@@ -341,16 +374,39 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
341
374
  capabilityAvailability,
342
375
  allowUnresolvedIntake: options.allowUnresolvedIntake === true,
343
376
  });
377
+ const intakeWarnings = validateIntakeAnswers(answers, {
378
+ request,
379
+ outcome,
380
+ platforms: inspection.platforms,
381
+ designSignals: inspection.designSignals,
382
+ });
344
383
  if (intake.remainingBlocking > 0 && !intake.acknowledged_unresolved_blocking) {
345
- const blockingIds = intake.questions
346
- .filter((question) => question.blocksImplementationWhenMissing)
347
- .map((question) => question.id);
384
+ const blockingQuestions = intake.questions.filter((question) => question.blocksImplementationWhenMissing);
385
+ const blockingIds = blockingQuestions.map((question) => question.id);
348
386
  const answerExample = blockingIds.map((id) => `${id}=<value>`).join(" --answer ");
349
387
  return {
350
388
  status: "needs-clarification",
351
389
  exitCode: 7,
352
390
  outcome,
353
391
  intake,
392
+ decisionChecklist: blockingQuestions.map((question) => ({
393
+ id: question.id,
394
+ question: question.question,
395
+ why: question.why,
396
+ options: question.options,
397
+ })),
398
+ unattended: {
399
+ status: "blocked",
400
+ mode: "handoff-required",
401
+ reason: "Blocking intake answers are required before this surface can be initialized.",
402
+ instruction: "If the user is not available, stop and return the blocking intake questions. Do not invent scope, route, identity, region, design, or capability answers.",
403
+ allowedActions: ["read-only inspect/plan output", "collect exact --answer values from the user"],
404
+ resumeWhen: [
405
+ "every blocking intake id has an explicit user-provided answer",
406
+ "init is re-run with one repeated --answer id=value flag per blocking id",
407
+ ],
408
+ },
409
+ ...(intakeWarnings.length > 0 && { warnings: intakeWarnings }),
354
410
  nextStep: "Run `vise plan` and surface the blocking intake questions to the customer. " +
355
411
  `Then re-run with their answers (one --answer per id): vise init --request <request> --answer ${answerExample}. ` +
356
412
  "Each --answer takes a single id=value; repeat the flag per id (do not comma-join). " +
@@ -393,6 +449,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
393
449
  });
394
450
  await writeJson(path.join(sidecarDir(repoRoot), "inspection.json"), inspection);
395
451
  await writeFile(path.join(sidecarDir(repoRoot), "README.md"), sidecarReadme(compliance), "utf8");
452
+ const runtimeSmokeTemplate = await writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome);
396
453
  const checkSnapshot = await checkCompliance(repoPath);
397
454
  await writeJson(path.join(sidecarDir(repoRoot), "findings.json"), {
398
455
  snapshot_at: compliance.generated_at,
@@ -401,8 +458,10 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
401
458
  status: checkSnapshot.status,
402
459
  summary: checkSnapshot.summary,
403
460
  rules: checkSnapshot.rules,
461
+ ...(checkSnapshot.completeness ? { completeness: checkSnapshot.completeness } : {}),
462
+ ...(checkSnapshot.selectedOptionalCapabilities ? { selectedOptionalCapabilities: checkSnapshot.selectedOptionalCapabilities } : {}),
404
463
  });
405
- const warnings = [];
464
+ const warnings = [...intakeWarnings];
406
465
  if (engagement && engagement.scope.outcomes.length > 0 && !engagement.scope.outcomes.includes(outcome)) {
407
466
  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.`);
408
467
  }
@@ -417,6 +476,7 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
417
476
  ...(uxHarness ? { ux_harness: uxHarnessSummary(uxHarness) } : {}),
418
477
  ...(compliance.design_contract && { design_contract: compliance.design_contract }),
419
478
  ...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
479
+ ...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
420
480
  intake: {
421
481
  status: intake.status,
422
482
  remainingBlocking: intake.remainingBlocking,
@@ -425,7 +485,127 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
425
485
  answers: intake.answers,
426
486
  },
427
487
  ...(warnings.length > 0 && { warnings }),
428
- nextStep: "Run vise check, then implement until rules pass deterministically or are attested.",
488
+ nextStep: runtimeSmokeTemplate
489
+ ? "Run vise check, implement until rules pass, then cold-launch the app, capture VISE_SMOKE logs, run vise smoke, and rerun vise check before completing the surface."
490
+ : "Run vise check, then implement until rules pass deterministically or are attested.",
491
+ };
492
+ }
493
+ function runtimeSmokeSurfaceForOutcome(outcome) {
494
+ switch (outcome) {
495
+ case "add-community":
496
+ return {
497
+ id: "community",
498
+ expect: "populated",
499
+ launch: "Cold-launch into the product route that lists/selects the current community target.",
500
+ };
501
+ case "add-feed":
502
+ return {
503
+ id: "feed",
504
+ expect: "populated",
505
+ launch: "Cold-launch into the product route that mounts the selected feed/post collection.",
506
+ };
507
+ case "add-comments":
508
+ return {
509
+ id: "comments",
510
+ expect: "populated",
511
+ launch: "Cold-launch into a product post/thread route with a visible parent post and comment collection.",
512
+ };
513
+ case "add-chat":
514
+ return {
515
+ id: "chat",
516
+ expect: "populated",
517
+ launch: "Cold-launch into the product inbox or selected channel route with readable messages.",
518
+ };
519
+ case "add-follow":
520
+ return {
521
+ id: "profile",
522
+ expect: "populated",
523
+ launch: "Cold-launch into a product profile/member route with relationship data visible.",
524
+ };
525
+ case "add-notifications":
526
+ return {
527
+ id: "notifications",
528
+ expect: "resolved",
529
+ launch: "Cold-launch into the product notification tray/settings route.",
530
+ };
531
+ default:
532
+ return null;
533
+ }
534
+ }
535
+ function runtimeSmokeCaptureRecipe(platform) {
536
+ const normalized = platform.toLowerCase();
537
+ if (normalized === "android") {
538
+ return {
539
+ log: "adb logcat",
540
+ steps: [
541
+ "adb logcat -c",
542
+ "Cold-launch the app into the social route and wait until the visible collection resolves.",
543
+ "adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log",
544
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
545
+ ],
546
+ };
547
+ }
548
+ if (normalized === "ios") {
549
+ return {
550
+ log: "simctl unified log",
551
+ steps: [
552
+ "xcrun simctl launch --terminate-running-process <device> <bundle-id>",
553
+ "Wait until the visible collection resolves.",
554
+ "xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log",
555
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
556
+ ],
557
+ };
558
+ }
559
+ if (normalized === "flutter") {
560
+ return {
561
+ log: "flutter run logs or adb logcat",
562
+ steps: [
563
+ "Use an ignored define file such as .dart_tool/vise-runtime-defines.env for runtime values.",
564
+ "Cold-launch the app and wait until the collection resolves.",
565
+ "Capture debugPrint VISE_SMOKE lines from flutter run logs or adb logcat into sp-vise/evidence/runtime-smoke.log.",
566
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
567
+ ],
568
+ };
569
+ }
570
+ if (normalized === "react-native") {
571
+ return {
572
+ log: "Metro/device logs",
573
+ steps: [
574
+ "Cold-launch the app and wait until the collection resolves.",
575
+ "Capture console.info VISE_SMOKE lines from Metro or adb logcat into sp-vise/evidence/runtime-smoke.log.",
576
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
577
+ ],
578
+ };
579
+ }
580
+ return {
581
+ log: "browser or app runtime log",
582
+ steps: [
583
+ "Cold-load the route that mounts the collection surface.",
584
+ "Capture console.info VISE_SMOKE lines into sp-vise/evidence/runtime-smoke.log.",
585
+ "vise smoke . --log sp-vise/evidence/runtime-smoke.log",
586
+ ],
587
+ };
588
+ }
589
+ async function writeRuntimeSmokeTemplateIfNeeded(repoRoot, platform, outcome) {
590
+ const surface = runtimeSmokeSurfaceForOutcome(outcome);
591
+ if (!surface)
592
+ return null;
593
+ const smokePath = path.join(sidecarDir(repoRoot), "smoke.json");
594
+ if (existsSync(smokePath)) {
595
+ return { status: "existing", path: "sp-vise/smoke.json", surface: surface.id };
596
+ }
597
+ const platformName = platform || "unknown";
598
+ await writeJson(smokePath, {
599
+ platform: platformName,
600
+ surfaces: [surface],
601
+ capture: runtimeSmokeCaptureRecipe(platformName),
602
+ });
603
+ return {
604
+ status: "created",
605
+ path: "sp-vise/smoke.json",
606
+ surface: surface.id,
607
+ expect: surface.expect,
608
+ capture: runtimeSmokeCaptureRecipe(platformName),
429
609
  };
430
610
  }
431
611
  function intakeAuditFor(args) {
@@ -438,79 +618,61 @@ function intakeAuditFor(args) {
438
618
  designSignals: args.designSignals,
439
619
  answers: args.answers,
440
620
  });
441
- const questions = [...getOutcomeDefinition(args.outcome).intakeQuestions(ctx)];
442
- if (args.designReview?.status === "needs-confirmation" && !hasAnswer(ctx.answers, DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID)) {
443
- questions.push({
444
- id: DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID,
445
- question: `Open ${args.designReview.previewPath} and confirm whether this design preview/contract should be used for the social UI.`,
446
- why: "A design contract must be explicitly accepted before Vise can feed it forward or let the agent claim design conformance. If it is wrong, reject it and provide the correct design source.",
447
- required: true,
448
- blocksImplementationWhenMissing: true,
449
- options: ["yes", "no"],
450
- });
451
- }
452
- if (args.designReview?.status === "rejected" && !hasAnswer(ctx.answers, "design_source")) {
453
- questions.push({
454
- id: "design_source",
455
- question: "What design source should replace the rejected preview/contract?",
456
- why: "The current design preview was rejected, so Vise must not use it. Provide a prototype, screenshot, theme/token file, or explicitly say to proceed without a design-conformance claim.",
457
- required: true,
458
- blocksImplementationWhenMissing: true,
459
- });
460
- }
461
- if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
462
- questions.push({
463
- id: "design_source",
464
- question: "Where are the app's design tokens, theme, or reusable UI components defined?",
465
- why: "The user asked to match the existing design, and no local design source was detected.",
466
- required: true,
467
- blocksImplementationWhenMissing: true,
468
- });
469
- }
470
- if (ctx.mentionsDesign && ctx.designSignals.length > 0 && !hasAnswer(ctx.answers, "confirm_design_source")) {
471
- questions.push({
472
- id: "confirm_design_source",
473
- question: `Should the social UI use the detected design source(s): ${ctx.designSignals.map((signal) => signal.file).join(", ")}?`,
474
- why: "Vise found likely design evidence, but the user or host agent should confirm it is the right source before UI edits.",
475
- required: true,
476
- blocksImplementationWhenMissing: false,
477
- options: ["yes", "use another source"],
478
- });
479
- }
480
- if (args.designBrief &&
481
- args.designReview?.status === "accepted" &&
482
- (args.outcome === "add-feed" || args.outcome === "add-chat") &&
483
- !args.designBrief.roles.some((role) => role.role === "primaryAction") &&
484
- !hasAnswer(ctx.answers, "primary_action_token")) {
485
- questions.push({
486
- id: "primary_action_token",
487
- 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.",
488
- 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.",
489
- required: false,
490
- blocksImplementationWhenMissing: false,
491
- });
492
- }
493
- const availableOptionalIds = availableOptionalCapabilityIds(args.capabilityAvailability);
494
- const optionalChoices = args.outcome === "add-feed" ? optionalCapabilityChecklist(args.outcome, availableOptionalIds) : [];
495
- if (args.outcome === "add-feed" && optionalChoices.length > 0 && !hasAnswer(ctx.answers, "feed_optional_capabilities")) {
496
- questions.push({
497
- id: "feed_optional_capabilities",
498
- question: `Which optional feed capabilities should be in scope on ${platform}: ${optionalChoices.map((capability) => capability.id).join(", ")}, or none?`,
499
- why: "These capabilities are useful for full feeds, but should become enforceable only after the customer explicitly opts in. Vise only lists capabilities that appear available in the platform SDK surface; unsupported capabilities must be treated as out of scope or confirmed from docs.",
500
- required: false,
501
- blocksImplementationWhenMissing: false,
502
- options: ["none", ...optionalChoices.map((capability) => capability.id)],
503
- });
504
- }
505
- const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
621
+ const outcomeQuestions = getOutcomeDefinition(args.outcome).intakeQuestions(ctx);
622
+ const { questions, remainingBlocking, status } = assembleIntake({
623
+ ctx,
624
+ outcomeQuestions,
625
+ outcome: args.outcome,
626
+ designBrief: args.designBrief,
627
+ capabilityAvailability: args.capabilityAvailability,
628
+ designReview: args.designReview,
629
+ });
506
630
  return {
507
- status: remainingBlocking > 0 ? "needs-clarification" : "ready",
631
+ status,
508
632
  questions,
509
633
  answers: args.answers,
510
634
  remainingBlocking,
511
635
  acknowledged_unresolved_blocking: args.allowUnresolvedIntake && remainingBlocking > 0,
512
636
  };
513
637
  }
638
+ const APPENDED_INTAKE_QUESTION_IDS = new Set([
639
+ DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID,
640
+ "design_source",
641
+ "confirm_design_source",
642
+ "primary_action_token",
643
+ "feed_optional_capabilities",
644
+ ]);
645
+ function validateIntakeAnswers(answers, ctx) {
646
+ const warnings = [];
647
+ const catalogCtx = planContextFor({
648
+ request: ctx.request,
649
+ outcome: ctx.outcome,
650
+ platform: preferredPlatform(ctx.platforms),
651
+ platforms: ctx.platforms,
652
+ designSignals: ctx.designSignals,
653
+ answers: {},
654
+ });
655
+ const catalog = getOutcomeDefinition(ctx.outcome).intakeQuestions(catalogCtx);
656
+ const byId = new Map(catalog.map((question) => [question.id, question]));
657
+ for (const [key, value] of Object.entries(answers)) {
658
+ const question = byId.get(key);
659
+ if (!question && !APPENDED_INTAKE_QUESTION_IDS.has(key)) {
660
+ warnings.push(`Intake answer "${key}" was ignored — it does not match any intake question for outcome "${ctx.outcome}". Run \`vise plan\` to see the valid question ids.`);
661
+ continue;
662
+ }
663
+ const optionsAreTokens = Array.isArray(question?.options) &&
664
+ question.options.length > 0 &&
665
+ question.options.every((option) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(option));
666
+ if (question && optionsAreTokens) {
667
+ const tokens = String(value).split(",").map((token) => token.trim()).filter(Boolean);
668
+ const invalid = tokens.filter((token) => !question.options.includes(token));
669
+ if (invalid.length > 0) {
670
+ warnings.push(`Intake answer "${key}=${value}" includes value(s) outside the allowed options for "${key}" (${question.options.join(", ")}). The unexpected value(s) [${invalid.join(", ")}] may be ignored; re-run with a valid option.`);
671
+ }
672
+ }
673
+ }
674
+ return warnings;
675
+ }
514
676
  function creativeSelectionSummary(selection) {
515
677
  return {
516
678
  status: selection.status,
@@ -680,7 +842,7 @@ async function ruleFreshness(rule) {
680
842
  ...(verifiedAgainst && { verified_against: verifiedAgainst }),
681
843
  };
682
844
  }
683
- export async function checkCompliance(repoPath) {
845
+ export async function checkCompliance(repoPath, options = {}) {
684
846
  const repoRoot = path.resolve(repoPath);
685
847
  const compliance = await readCompliance(repoRoot);
686
848
  const rules = await rulesById();
@@ -700,16 +862,42 @@ export async function checkCompliance(repoPath) {
700
862
  const recordedPlatforms = compliance.surface?.platforms || [];
701
863
  const platformsToValidate = Array.from(new Set([...detectedPlatforms, ...recordedPlatforms]));
702
864
  const platforms = platformsToValidate.length > 0 ? platformsToValidate : ["unknown"];
865
+ if (compliance.rules.length === 0) {
866
+ const expectedRules = await applicableRules(compliance.outcome, platforms);
867
+ if (expectedRules.length > 0) {
868
+ return {
869
+ status: "contract-drift",
870
+ exitCode: 4,
871
+ outcome: compliance.outcome,
872
+ surfacePath: compliance.surface?.path,
873
+ summary: { "contract-drift": 1 },
874
+ rules: [
875
+ {
876
+ ruleId: "contract.ruleset",
877
+ title: "Compliance ruleset is empty but a social.plus integration was detected",
878
+ severity: "error",
879
+ status: "stale",
880
+ reason: `sp-vise/compliance.json baked 0 compliance rules, but a social.plus integration was detected for platform(s) ${platforms.join(", ")} (${expectedRules.length} rule(s) apply). The sidecar was initialized before the platform was detectable (e.g. a CDN / no-package.json integration whose SDK usage is only in source) or is stale — a green here would certify unverified SDK code. Re-run \`vise init\` to bake the ruleset, then \`vise check\`.`,
881
+ },
882
+ ],
883
+ };
884
+ }
885
+ }
886
+ const { canonicalPlatform } = await import("./sdkFacts.js");
703
887
  const allFindings = await Promise.all(platforms.map((p) => validateSetup(inspection.effectiveRoot, p)));
704
888
  const findings = allFindings.flat();
705
889
  const findingsById = new Map();
890
+ const findingsByIdAll = new Map();
706
891
  for (const finding of findings) {
707
892
  findingsById.set(finding.ruleId, finding);
893
+ appendFinding(findingsByIdAll, finding.ruleId, finding);
708
894
  if (finding.sensorId) {
709
895
  findingsById.set(finding.sensorId, finding);
896
+ appendFinding(findingsByIdAll, finding.sensorId, finding);
710
897
  }
711
898
  }
712
899
  const attestations = await readAttestations(repoRoot);
900
+ const attestationHygiene = await readAttestationHygiene(repoRoot, compliance);
713
901
  const results = [];
714
902
  for (const ref of compliance.rules) {
715
903
  const rule = rules.get(ref.rule_id);
@@ -738,14 +926,18 @@ export async function checkCompliance(repoPath) {
738
926
  }
739
927
  const hasDeterministicChecks = (rule.enforcement.deterministic ?? []).length > 0;
740
928
  const isInferential = !hasDeterministicChecks && !!rule.enforcement.inferential;
741
- const finding = hasDeterministicChecks ? deterministicFinding(rule, findingsById) : undefined;
742
- if (hasDeterministicChecks && !finding) {
929
+ const deterministic = hasDeterministicChecks
930
+ ? await assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot)
931
+ : undefined;
932
+ const finding = deterministic?.finding;
933
+ if (hasDeterministicChecks && deterministic?.passed) {
743
934
  results.push({
744
935
  ...checkRuleIdentity(rule.id),
745
936
  title: rule.title,
746
937
  severity: rule.severity,
747
938
  status: "deterministic-pass",
748
- evidence: { source: "validate_setup", finding_absent: deterministicFindingIds(rule) },
939
+ pass_basis: deterministic.pass_basis,
940
+ evidence: deterministic.evidence,
749
941
  });
750
942
  continue;
751
943
  }
@@ -761,10 +953,10 @@ export async function checkCompliance(repoPath) {
761
953
  reason: rule.advisory
762
954
  ? "Advisory: informational only — does not affect compliance status."
763
955
  : rule.enforcement.attestation.allowed
764
- ? "Current deterministic check failed; previously synced deterministic-pass evidence is stale."
765
- : "Current deterministic check failed; this rule does not allow attestation.",
956
+ ? `Current deterministic check failed; previously synced deterministic-pass evidence is stale.${deterministic?.reason ? ` ${deterministic.reason}` : ""}`
957
+ : (deterministic?.reason ?? "Current deterministic check failed; this rule does not allow attestation."),
766
958
  finding,
767
- recommendation: finding?.recommendation,
959
+ recommendation: finding?.recommendation ?? deterministic?.recommendation,
768
960
  rationale: rule.rationale,
769
961
  current_rule: ruleSummary(rule),
770
962
  ...(failStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
@@ -795,6 +987,29 @@ export async function checkCompliance(repoPath) {
795
987
  });
796
988
  continue;
797
989
  }
990
+ if (attestation.file_scoped === true && isFileScopableRule(rule)) {
991
+ const coveredFiles = new Set((attestation.source_fingerprints ?? []).map((fp) => fp.path));
992
+ const uncovered = await ruleUncoveredViolators(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll, coveredFiles);
993
+ if (uncovered.length > 0) {
994
+ const scopeStatus = rule.advisory ? "advisory" : rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail";
995
+ const uncoveredPaths = uniqueStrings(uncovered.map((f) => f.file).filter((f) => !!f));
996
+ results.push({
997
+ ...checkRuleIdentity(rule.id),
998
+ title: rule.title,
999
+ severity: rule.severity,
1000
+ status: scopeStatus,
1001
+ reason: rule.advisory
1002
+ ? "Advisory: informational only — does not affect compliance status."
1003
+ : `Attestation covers ${[...coveredFiles].join(", ")}; the rule also fires in ${uncoveredPaths.join(", ")}, which this attestation does not cover. Review the new violation(s) and re-attest (or fix them).`,
1004
+ finding: uncovered[0],
1005
+ recommendation: uncovered[0].recommendation,
1006
+ rationale: rule.rationale,
1007
+ current_rule: ruleSummary(rule),
1008
+ ...(scopeStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
1009
+ });
1010
+ continue;
1011
+ }
1012
+ }
798
1013
  const deprecated = grandfathered && (rule.deprecated_versions ?? []).includes(attestation.rule_version);
799
1014
  results.push({
800
1015
  ...checkRuleIdentity(rule.id),
@@ -824,6 +1039,9 @@ export async function checkCompliance(repoPath) {
824
1039
  else if (isInferential) {
825
1040
  fallbackReason = "Inferential check required. Please provide a host-agent attestation.";
826
1041
  }
1042
+ else if (deterministic?.reason) {
1043
+ fallbackReason = deterministic.reason;
1044
+ }
827
1045
  else if (rule.enforcement.attestation.allowed) {
828
1046
  fallbackReason = "Deterministic check failed and no valid attestation exists.";
829
1047
  }
@@ -834,7 +1052,7 @@ export async function checkCompliance(repoPath) {
834
1052
  status: baseStatus,
835
1053
  reason: fallbackReason,
836
1054
  finding,
837
- recommendation: finding?.recommendation,
1055
+ recommendation: finding?.recommendation ?? deterministic?.recommendation,
838
1056
  current_rule: ruleSummary(rule),
839
1057
  ...(isInferential && { inferential_prompt: rule.enforcement.inferential?.prompt }),
840
1058
  ...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
@@ -858,49 +1076,99 @@ export async function checkCompliance(repoPath) {
858
1076
  }
859
1077
  }
860
1078
  const summary = summarize(results);
861
- const hasBlocked = results.some((result) => result.status === "blocked");
862
- const hasDeterministicFailure = results.some((result) => result.status === "deterministic-fail");
863
- const needsAttestation = results.some((result) => result.status === "attestation-needed" || result.status === "stale");
1079
+ const deterministicPasses = results.filter((result) => result.status === "deterministic-pass");
1080
+ const runtimeSmokePasses = deterministicPasses.filter((result) => result.pass_basis === "runtime-smoke").length;
1081
+ const undetectedPlatformPasses = deterministicPasses.filter((result) => result.pass_basis === "undetected-platform").length;
1082
+ const absencePasses = deterministicPasses.length - runtimeSmokePasses - undetectedPlatformPasses;
1083
+ const evidenceBasis = deterministicPasses.length > 0
1084
+ ? {
1085
+ absent: absencePasses,
1086
+ undetected_platform: undetectedPlatformPasses,
1087
+ ...(runtimeSmokePasses > 0 ? { runtime_smoke: runtimeSmokePasses } : {}),
1088
+ }
1089
+ : undefined;
1090
+ const evidenceBasisNote = evidenceBasis
1091
+ ? `${absencePasses + undetectedPlatformPasses} of the passing rules passed by ABSENCE (the sensor found no violation, which is not positive proof the feature was built).${undetectedPlatformPasses > 0
1092
+ ? ` ${undetectedPlatformPasses} of those are vacuous — the rule's platform was not detected in this project, so no sensor ran.`
1093
+ : ""}${runtimeSmokePasses > 0 ? ` ${runtimeSmokePasses} deterministic-pass rule(s) cleared with runtime-smoke evidence from a mounted app.` : ""} Treat absence-based passes as "no problem found", not "built and validated".`
1094
+ : undefined;
864
1095
  const sourceCompleteness = (await assessProjectCompleteness(inspection.effectiveRoot, compliance.outcome).catch(() => null)) ?? undefined;
865
1096
  const blockProvided = sourceCompleteness && sourceCompleteness.missing.length > 0
866
1097
  ? await installedBlockProvidedCapabilities(repoRoot).catch(() => [])
867
1098
  : [];
868
1099
  const completeness = sourceCompleteness ? applyBlockProvidedCompleteness(sourceCompleteness, blockProvided) : undefined;
869
- const hasCompletenessGap = (completeness?.missing.length ?? 0) > 0;
870
1100
  const selectedOptionalCapabilities = (await assessProjectSelectedOptionalCapabilities(inspection.effectiveRoot, compliance.outcome, compliance.selected_optional_capabilities ?? []).catch(() => null)) ?? undefined;
871
- const hasSelectedOptionalFailures = ((selectedOptionalCapabilities?.failed.length ?? 0) > 0) || ((selectedOptionalCapabilities?.unknown.length ?? 0) > 0);
872
- const status = hasBlocked
873
- ? "blocked"
874
- : hasDeterministicFailure
875
- ? "deterministic-failures"
876
- : needsAttestation
877
- ? "needs-attestation"
878
- : hasCompletenessGap
879
- ? "completeness-gap"
880
- : hasSelectedOptionalFailures
881
- ? "selected-capability-failures"
882
- : "green";
883
- const exitCode = hasBlocked
884
- ? 3
885
- : hasDeterministicFailure
886
- ? 2
887
- : needsAttestation
888
- ? 1
889
- : hasCompletenessGap
890
- ? 5
891
- : hasSelectedOptionalFailures
892
- ? 6
893
- : 0;
1101
+ const baselineFile = await readBaseline(repoRoot);
1102
+ let baselineReport;
1103
+ let gatedResults = results;
1104
+ let gatedMissing = completeness?.missing ?? [];
1105
+ let gatedSelectedFailed = selectedOptionalCapabilities?.failed ?? [];
1106
+ let gatedSelectedUnknown = selectedOptionalCapabilities?.unknown ?? [];
1107
+ if (options.newOnly) {
1108
+ if (!baselineFile) {
1109
+ baselineReport = { present: false, applied: false, reason: "No baseline recorded — `--new-only` gated on everything. Run `vise init --baseline` (or `vise baseline`) on the pre-existing codebase first." };
1110
+ }
1111
+ else if (baselineFile.ruleset_digest !== compliance.ruleset_digest) {
1112
+ baselineReport = { present: true, applied: false, stale: true, baselined_at: baselineFile.baselined_at, reason: "Baseline is stale: the ruleset changed since it was recorded, so it was NOT applied (gating on everything). Re-run `vise baseline` to refresh it against the current contract." };
1113
+ }
1114
+ else {
1115
+ const budget = new Map(Object.entries(baselineFile.findings));
1116
+ for (const result of results) {
1117
+ if (!BASELINE_NON_GREEN.has(result.status))
1118
+ continue;
1119
+ const key = baselineKeyFor(result);
1120
+ const remaining = budget.get(key) ?? 0;
1121
+ if (remaining > 0) {
1122
+ budget.set(key, remaining - 1);
1123
+ result.baselined = true;
1124
+ }
1125
+ }
1126
+ const baselinedMissing = new Set(baselineFile.completeness_missing);
1127
+ const baselinedSelected = new Set(baselineFile.selected_optional);
1128
+ gatedResults = results.filter((result) => !result.baselined);
1129
+ gatedMissing = (completeness?.missing ?? []).filter((item) => !baselinedMissing.has(item.id));
1130
+ gatedSelectedFailed = (selectedOptionalCapabilities?.failed ?? []).filter((item) => !baselinedSelected.has(item.id));
1131
+ gatedSelectedUnknown = (selectedOptionalCapabilities?.unknown ?? []).filter((id) => !baselinedSelected.has(id));
1132
+ baselineReport = {
1133
+ present: true,
1134
+ applied: true,
1135
+ baselined_at: baselineFile.baselined_at,
1136
+ excluded: {
1137
+ rules: results.filter((result) => result.baselined).length,
1138
+ completeness: (completeness?.missing.length ?? 0) - gatedMissing.length,
1139
+ selected_optional: ((selectedOptionalCapabilities?.failed.length ?? 0) - gatedSelectedFailed.length) +
1140
+ ((selectedOptionalCapabilities?.unknown.length ?? 0) - gatedSelectedUnknown.length),
1141
+ },
1142
+ 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.",
1143
+ };
1144
+ }
1145
+ }
1146
+ else if (baselineFile) {
1147
+ baselineReport = { present: true, applied: false, baselined_at: baselineFile.baselined_at, hint: "A brownfield baseline exists. Run `vise check --new-only` to gate only on findings introduced since the baseline (pre-existing findings are excluded)." };
1148
+ }
1149
+ const hasCompletenessGap = gatedMissing.length > 0;
1150
+ const hasSelectedOptionalFailures = gatedSelectedFailed.length > 0 || gatedSelectedUnknown.length > 0;
1151
+ const { status, exitCode } = deriveGate({
1152
+ hasBlocked: gatedResults.some((result) => result.status === "blocked"),
1153
+ hasDeterministicFailure: gatedResults.some((result) => result.status === "deterministic-fail"),
1154
+ needsAttestation: gatedResults.some((result) => result.status === "attestation-needed" || result.status === "stale"),
1155
+ hasCompletenessGap,
1156
+ hasSelectedOptionalFailures,
1157
+ });
894
1158
  const uxHarness = await readUxHarness(repoRoot);
895
1159
  const uxAssessment = await assessUxHarness(inspection.effectiveRoot, uxHarness, compliance.outcome);
896
- const experienceReport = buildExperienceReport({
897
- checkStatus: status,
898
- exitCode,
899
- summary,
900
- designContract: compliance.design_contract,
901
- uxAssessment,
902
- uxHarness,
903
- });
1160
+ const openReviewItems = await experienceOpenReviewItems(repoRoot, findings);
1161
+ const experienceReport = {
1162
+ ...buildExperienceReport({
1163
+ checkStatus: status,
1164
+ exitCode,
1165
+ summary,
1166
+ designContract: compliance.design_contract,
1167
+ uxAssessment,
1168
+ uxHarness,
1169
+ }),
1170
+ openReviewItems,
1171
+ };
904
1172
  return {
905
1173
  status,
906
1174
  exitCode,
@@ -908,10 +1176,14 @@ export async function checkCompliance(repoPath) {
908
1176
  surfacePath: compliance.surface?.path,
909
1177
  summary,
910
1178
  rules: results,
1179
+ ...(evidenceBasis ? { evidence_basis: evidenceBasis } : {}),
1180
+ ...(evidenceBasisNote ? { evidence_basis_note: evidenceBasisNote } : {}),
911
1181
  ...(completeness && (completeness.missing.length > 0 || completeness.optedOut.length > 0 || completeness.present.length > 0) ? { completeness } : {}),
912
1182
  ...(selectedOptionalCapabilities ? { selectedOptionalCapabilities } : {}),
1183
+ ...(baselineReport ? { baseline: baselineReport } : {}),
913
1184
  uxHarness: uxAssessment,
914
1185
  experienceReport,
1186
+ ...(attestationHygiene ? { attestation_hygiene: attestationHygiene } : {}),
915
1187
  };
916
1188
  }
917
1189
  export async function generateExperienceReport(repoPath, options = {}) {
@@ -979,6 +1251,92 @@ function sensorReviewGaps(raw) {
979
1251
  }
980
1252
  return gaps.slice(0, 50);
981
1253
  }
1254
+ async function experienceOpenReviewItems(repoRoot, findings) {
1255
+ const intake = await readIntakeReviewItems(repoRoot);
1256
+ const validateFindings = validateFindingReviewItems(findings);
1257
+ const needsReview = intake.unresolvedNonblockingIntake.length > 0 ||
1258
+ intake.acknowledgedBlockingIntake.length > 0 ||
1259
+ validateFindings.length > 0;
1260
+ return {
1261
+ status: needsReview ? "needs-review" : "clear",
1262
+ unresolvedNonblockingIntake: intake.unresolvedNonblockingIntake,
1263
+ acknowledgedBlockingIntake: intake.acknowledgedBlockingIntake,
1264
+ validateFindings,
1265
+ nextStep: needsReview
1266
+ ? "Surface these items in the final handoff. Answer or explicitly scope unresolved intake, and treat validate findings as remaining review work even when vise check is green."
1267
+ : "No unresolved intake or validate findings were observed in the current report inputs.",
1268
+ };
1269
+ }
1270
+ async function readIntakeReviewItems(repoRoot) {
1271
+ const sidecar = await readJsonIfExists(path.join(sidecarDir(repoRoot), "intake.json"));
1272
+ if (!sidecar) {
1273
+ return { unresolvedNonblockingIntake: [], acknowledgedBlockingIntake: [] };
1274
+ }
1275
+ const answers = sidecar.answers && typeof sidecar.answers === "object" && !Array.isArray(sidecar.answers)
1276
+ ? sidecar.answers
1277
+ : {};
1278
+ const questions = Array.isArray(sidecar.questions) ? sidecar.questions : [];
1279
+ const unresolved = questions
1280
+ .map(intakeQuestionReviewItem)
1281
+ .filter((item) => Boolean(item))
1282
+ .filter((item) => !hasNonEmptyAnswer(answers, item.id));
1283
+ const acknowledgedBlocking = sidecar.acknowledged_unresolved_blocking === true
1284
+ ? unresolved.filter((item) => item.blocking)
1285
+ : [];
1286
+ return {
1287
+ unresolvedNonblockingIntake: unresolved.filter((item) => !item.blocking).map(stripBlockingFlag),
1288
+ acknowledgedBlockingIntake: acknowledgedBlocking.map(stripBlockingFlag),
1289
+ };
1290
+ }
1291
+ function intakeQuestionReviewItem(raw) {
1292
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1293
+ return undefined;
1294
+ }
1295
+ const question = raw;
1296
+ if (typeof question.id !== "string" || typeof question.question !== "string") {
1297
+ return undefined;
1298
+ }
1299
+ return {
1300
+ id: question.id,
1301
+ question: question.question,
1302
+ ...(typeof question.why === "string" ? { why: question.why } : {}),
1303
+ ...(Array.isArray(question.options) ? { options: question.options.filter((option) => typeof option === "string") } : {}),
1304
+ blocking: question.blocksImplementationWhenMissing === true,
1305
+ };
1306
+ }
1307
+ function stripBlockingFlag(item) {
1308
+ const { blocking: _blocking, ...rest } = item;
1309
+ return rest;
1310
+ }
1311
+ function hasNonEmptyAnswer(answers, id) {
1312
+ return Object.prototype.hasOwnProperty.call(answers, id) && String(answers[id] ?? "").trim() !== "";
1313
+ }
1314
+ function validateFindingReviewItems(findings) {
1315
+ const items = [];
1316
+ const seen = new Set();
1317
+ for (const finding of findings) {
1318
+ if (finding.severity !== "warning" && finding.severity !== "error") {
1319
+ continue;
1320
+ }
1321
+ const key = `${finding.ruleId}|${finding.sensorId ?? ""}|${finding.file ?? ""}|${finding.message}`;
1322
+ if (seen.has(key)) {
1323
+ continue;
1324
+ }
1325
+ seen.add(key);
1326
+ items.push({
1327
+ ruleId: finding.ruleId,
1328
+ ...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
1329
+ severity: finding.severity,
1330
+ message: finding.message,
1331
+ ...(finding.file ? { file: finding.file } : {}),
1332
+ ...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
1333
+ });
1334
+ if (items.length >= 50) {
1335
+ break;
1336
+ }
1337
+ }
1338
+ return items;
1339
+ }
982
1340
  function fallbackExperienceReport(check) {
983
1341
  const uxAssessment = check.uxHarness ?? {
984
1342
  status: "absent",
@@ -1028,7 +1386,14 @@ export async function syncCompliance(repoPath) {
1028
1386
  if (existing?.status === "attested") {
1029
1387
  continue;
1030
1388
  }
1031
- const attestation = buildAttestation(compliance, rule, "spf-deterministic", "high", undefined, "Deterministic check passed.", result.evidence ?? {});
1389
+ const passBasis = result.pass_basis ?? "absent";
1390
+ const confidence = passBasis === "runtime-smoke" ? "high" : passBasis === "undetected-platform" ? "low" : "medium";
1391
+ const rationale = passBasis === "runtime-smoke"
1392
+ ? "Deterministic check passed: runtime mount-smoke evidence shows the declared collection surface(s) resolved in the running app."
1393
+ : passBasis === "undetected-platform"
1394
+ ? "Deterministic check passed by absence, but this rule targets a platform not detected in this project, so no sensor exercised it — treat as not-yet-validated."
1395
+ : "Deterministic check passed: the sensor ran and found no violation. Absence of a finding is not positive proof the feature was built.";
1396
+ const attestation = buildAttestation(compliance, rule, "spf-deterministic", confidence, undefined, rationale, { ...(result.evidence ?? {}), pass_basis: passBasis });
1032
1397
  await writeJson(filePath, attestation);
1033
1398
  written.push(filePath);
1034
1399
  }
@@ -1047,6 +1412,7 @@ export async function syncCompliance(repoPath) {
1047
1412
  written,
1048
1413
  removed,
1049
1414
  last_synced_at: compliance.last_synced_at,
1415
+ ...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
1050
1416
  };
1051
1417
  }
1052
1418
  export async function attestRule(args) {
@@ -1090,9 +1456,16 @@ export async function attestRule(args) {
1090
1456
  throw new Error(`Rule does not allow local human attestations: ${args.ruleId}`);
1091
1457
  }
1092
1458
  validateEvidence(rule, args.evidence);
1459
+ const { warning: sensorWarning, violatorFiles } = await liveSensorContradiction(repoRoot, compliance, rule);
1093
1460
  await mkdir(attestationsDir(repoRoot), { recursive: true });
1094
- const sourceFingerprints = await collectSourceFingerprints(repoRoot, sourceRootForCompliance(repoRoot, compliance), args.evidence);
1095
- const attestation = buildAttestation(compliance, rule, args.signer, args.confidence, args.identity, args.rationale, args.evidence, sourceFingerprints);
1461
+ const sourceRoot = sourceRootForCompliance(repoRoot, compliance);
1462
+ const evidenceFingerprints = await collectSourceFingerprints(repoRoot, sourceRoot, args.evidence);
1463
+ const fileScoped = isFileScopableRule(rule);
1464
+ const violatorFingerprints = fileScoped && violatorFiles.length > 0
1465
+ ? await collectViolatorFingerprints(repoRoot, sourceRoot, violatorFiles, evidenceFingerprints)
1466
+ : [];
1467
+ const sourceFingerprints = [...evidenceFingerprints, ...violatorFingerprints];
1468
+ const attestation = buildAttestation(compliance, rule, args.signer, args.confidence, args.identity, args.rationale, args.evidence, sourceFingerprints, fileScoped);
1096
1469
  const filePath = path.join(attestationsDir(repoRoot), attestationPathFor(contractRuleId));
1097
1470
  await writeJson(filePath, attestation);
1098
1471
  return {
@@ -1101,6 +1474,8 @@ export async function attestRule(args) {
1101
1474
  destination: filePath,
1102
1475
  signer_claim: attestation.signer_claim,
1103
1476
  source_fingerprints: sourceFingerprints,
1477
+ ...(fileScoped ? { file_scoped: true, scoped_files: [...new Set(sourceFingerprints.map((fp) => fp.path))] } : {}),
1478
+ ...(sensorWarning ? { sensor_warning: sensorWarning } : {}),
1104
1479
  };
1105
1480
  }
1106
1481
  export async function explainRule(ruleId) {
@@ -1124,6 +1499,9 @@ export async function explainRule(ruleId) {
1124
1499
  title: candidate.title,
1125
1500
  severity: candidate.severity,
1126
1501
  rationale: candidate.rationale,
1502
+ ...(candidate.feedforward ? { feedforward: candidate.feedforward } : {}),
1503
+ ...(candidate.symptoms && candidate.symptoms.length > 0 ? { symptoms: candidate.symptoms } : {}),
1504
+ ...(candidate.enforcement.inferential ? { inferential: candidate.enforcement.inferential } : {}),
1127
1505
  applies_when: candidate.applies_when,
1128
1506
  attestation: candidate.enforcement.attestation,
1129
1507
  summary: ruleSummary(candidate),
@@ -1142,6 +1520,9 @@ export async function explainRule(ruleId) {
1142
1520
  title: rule.title,
1143
1521
  severity: rule.severity,
1144
1522
  rationale: rule.rationale,
1523
+ ...(rule.feedforward ? { feedforward: rule.feedforward } : {}),
1524
+ ...(rule.symptoms && rule.symptoms.length > 0 ? { symptoms: rule.symptoms } : {}),
1525
+ ...(rule.enforcement.inferential ? { inferential: rule.enforcement.inferential } : {}),
1145
1526
  applies_when: rule.applies_when,
1146
1527
  compatible_with: rule.compatible_with,
1147
1528
  deterministic: rule.enforcement.deterministic ?? [],
@@ -1151,9 +1532,44 @@ export async function explainRule(ruleId) {
1151
1532
  freshness: await ruleFreshness(rule),
1152
1533
  };
1153
1534
  }
1154
- export async function statusCompliance(repoPath) {
1535
+ export async function listRules() {
1536
+ const rules = await rulesById();
1537
+ const byPublic = new Map();
1538
+ for (const rule of rules.values()) {
1539
+ const publicId = publicProductRuleId(rule.id);
1540
+ let entry = byPublic.get(publicId);
1541
+ if (!entry) {
1542
+ entry = { id: publicId, contract_rule_ids: new Set(), titles: new Set(), severities: new Set(), outcomes: new Set(), platforms: new Set() };
1543
+ byPublic.set(publicId, entry);
1544
+ }
1545
+ entry.contract_rule_ids.add(rule.id);
1546
+ entry.titles.add(rule.title);
1547
+ entry.severities.add(rule.severity);
1548
+ for (const outcome of rule.applies_when.outcomes ?? [])
1549
+ entry.outcomes.add(outcome);
1550
+ for (const platform of rule.applies_when.platforms ?? [])
1551
+ entry.platforms.add(platform);
1552
+ }
1553
+ const entries = [...byPublic.values()]
1554
+ .sort((a, b) => a.id.localeCompare(b.id))
1555
+ .map((entry) => ({
1556
+ id: entry.id,
1557
+ contract_rule_ids: [...entry.contract_rule_ids].sort(),
1558
+ title: [...entry.titles][0],
1559
+ severity: [...entry.severities].sort()[0],
1560
+ outcomes: [...entry.outcomes].sort(),
1561
+ platforms: [...entry.platforms].sort(),
1562
+ }));
1563
+ return {
1564
+ kind: "rule-index",
1565
+ count: entries.length,
1566
+ hint: "Run `vise explain <id>` with any id below (a public product id or a contract_rule_id) for the full rule, evidence, and attestation policy.",
1567
+ rules: entries,
1568
+ };
1569
+ }
1570
+ export async function statusCompliance(repoPath, options = {}) {
1155
1571
  const repoRoot = path.resolve(repoPath);
1156
- const check = await checkCompliance(repoPath);
1572
+ const check = await checkCompliance(repoPath, options);
1157
1573
  const engagement = await readEngagement(repoRoot);
1158
1574
  const compliance = await readCompliance(repoRoot).catch(() => null);
1159
1575
  return {
@@ -1162,6 +1578,11 @@ export async function statusCompliance(repoPath) {
1162
1578
  outcome: check.outcome,
1163
1579
  surfacePath: check.surfacePath,
1164
1580
  summary: check.summary,
1581
+ ...(check.evidence_basis ? { evidence_basis: check.evidence_basis } : {}),
1582
+ ...(check.evidence_basis_note ? { evidence_basis_note: check.evidence_basis_note } : {}),
1583
+ ...(check.completeness ? { completeness: check.completeness } : {}),
1584
+ ...(check.selectedOptionalCapabilities ? { selectedOptionalCapabilities: check.selectedOptionalCapabilities } : {}),
1585
+ ...(check.attestation_hygiene ? { attestation_hygiene: check.attestation_hygiene } : {}),
1165
1586
  ...(compliance?.design_contract && { design_contract: compliance.design_contract }),
1166
1587
  ...(engagement && {
1167
1588
  engagement: {
@@ -1331,6 +1752,187 @@ function contractDrift(compliance, rules) {
1331
1752
  }
1332
1753
  return results;
1333
1754
  }
1755
+ async function assessDeterministicChecks(rule, findingsById, platforms, canonicalPlatform, repoRoot) {
1756
+ const checks = rule.enforcement.deterministic ?? [];
1757
+ const passedEvidence = [];
1758
+ let passBasis = "absent";
1759
+ for (const check of checks) {
1760
+ if (check.check === "validator-finding-absent") {
1761
+ const finding = findingsById.get(check.finding_rule_id);
1762
+ if (finding) {
1763
+ return {
1764
+ passed: false,
1765
+ finding,
1766
+ reason: "Current deterministic validator check failed.",
1767
+ recommendation: finding.recommendation,
1768
+ };
1769
+ }
1770
+ passedEvidence.push({ source: "validate_setup", finding_absent: [check.finding_rule_id] });
1771
+ continue;
1772
+ }
1773
+ if (check.check === "runtime-smoke-evidence-passed") {
1774
+ const smoke = await assessRuntimeSmokeEvidence(repoRoot, check);
1775
+ if (!smoke.passed) {
1776
+ return smoke;
1777
+ }
1778
+ if (smoke.evidence) {
1779
+ passedEvidence.push(smoke.evidence);
1780
+ }
1781
+ passBasis = "runtime-smoke";
1782
+ continue;
1783
+ }
1784
+ const unknown = check;
1785
+ return {
1786
+ passed: false,
1787
+ reason: `Unsupported deterministic check kind: ${String(unknown.check ?? "(missing)")}.`,
1788
+ };
1789
+ }
1790
+ if (passBasis !== "runtime-smoke") {
1791
+ const identity = checkRuleIdentity(rule.id);
1792
+ const rulePlatform = identity.validator?.platform;
1793
+ passBasis =
1794
+ rulePlatform && !platforms.some((p) => canonicalPlatform(p) === canonicalPlatform(rulePlatform))
1795
+ ? "undetected-platform"
1796
+ : "absent";
1797
+ }
1798
+ const validatorAbsent = deterministicFindingIds(rule);
1799
+ const runtimeSmoke = passedEvidence.find((item) => item.source === "runtime_smoke");
1800
+ let evidence;
1801
+ if (runtimeSmoke && validatorAbsent.length > 0) {
1802
+ evidence = {
1803
+ source: "deterministic_checks",
1804
+ validate_setup: { finding_absent: validatorAbsent },
1805
+ runtime_smoke: runtimeSmoke,
1806
+ };
1807
+ }
1808
+ else if (runtimeSmoke) {
1809
+ evidence = runtimeSmoke;
1810
+ }
1811
+ else {
1812
+ evidence = { source: "validate_setup", finding_absent: validatorAbsent };
1813
+ }
1814
+ return {
1815
+ passed: true,
1816
+ pass_basis: passBasis,
1817
+ evidence,
1818
+ };
1819
+ }
1820
+ async function assessRuntimeSmokeEvidence(repoRoot, check) {
1821
+ const relativePath = check.path ?? "sp-vise/evidence/runtime-smoke.json";
1822
+ const evidencePath = path.resolve(repoRoot, relativePath);
1823
+ const displayPath = path.relative(repoRoot, evidencePath).split(path.sep).join(path.posix.sep);
1824
+ const recommendation = runtimeSmokeCaptureRecommendationText();
1825
+ if (!pathInside(repoRoot, evidencePath)) {
1826
+ return {
1827
+ passed: false,
1828
+ reason: `Runtime smoke evidence path must stay inside the repository: ${relativePath}`,
1829
+ recommendation,
1830
+ };
1831
+ }
1832
+ let parsed;
1833
+ try {
1834
+ parsed = JSON.parse(await readFile(evidencePath, "utf8"));
1835
+ }
1836
+ catch {
1837
+ return {
1838
+ passed: false,
1839
+ reason: `Missing or unreadable runtime smoke evidence at ${displayPath}.`,
1840
+ recommendation,
1841
+ };
1842
+ }
1843
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
1844
+ const surfaceResults = results.filter(isRuntimeSmokeSurfaceResult);
1845
+ const failing = surfaceResults.filter((result) => result.verdict !== "pass");
1846
+ const evidence = {
1847
+ source: "runtime_smoke",
1848
+ file: displayPath,
1849
+ passed: parsed.passed === true,
1850
+ ...(typeof parsed.platform === "string" ? { platform: parsed.platform } : {}),
1851
+ results: surfaceResults,
1852
+ };
1853
+ if (parsed.passed !== true) {
1854
+ const failedSurfaces = failing.map((result) => `${result.surface}:${result.state}`).join(", ");
1855
+ return {
1856
+ passed: false,
1857
+ evidence,
1858
+ reason: failedSurfaces
1859
+ ? `Runtime smoke evidence at ${displayPath} did not pass (${failedSurfaces}).`
1860
+ : `Runtime smoke evidence at ${displayPath} did not pass.`,
1861
+ recommendation,
1862
+ };
1863
+ }
1864
+ if (surfaceResults.length === 0) {
1865
+ return {
1866
+ passed: false,
1867
+ evidence,
1868
+ reason: `Runtime smoke evidence at ${displayPath} has passed=true but no surface results; rerun \`vise smoke\` so the gate can verify a mounted collection surface.`,
1869
+ recommendation,
1870
+ };
1871
+ }
1872
+ if (failing.length > 0) {
1873
+ return {
1874
+ passed: false,
1875
+ evidence,
1876
+ reason: `Runtime smoke evidence at ${displayPath} has passed=true but contains failing surface results (${failing.map((result) => result.surface).join(", ")}).`,
1877
+ recommendation,
1878
+ };
1879
+ }
1880
+ const smokeConfig = await readJsonIfExists(path.join(sidecarDir(repoRoot), "smoke.json"));
1881
+ const expectedSurfaceIds = Array.isArray(smokeConfig?.surfaces)
1882
+ ? smokeConfig.surfaces.map((surface) => surface.id).filter((id) => typeof id === "string" && id.length > 0)
1883
+ : [];
1884
+ if (expectedSurfaceIds.length > 0) {
1885
+ const passedSurfaceIds = new Set(surfaceResults.filter((result) => result.verdict === "pass").map((result) => result.surface));
1886
+ const missing = expectedSurfaceIds.filter((id) => !passedSurfaceIds.has(id));
1887
+ if (missing.length > 0) {
1888
+ return {
1889
+ passed: false,
1890
+ evidence,
1891
+ reason: `Runtime smoke evidence at ${displayPath} does not include passing results for declared collection surface(s): ${missing.join(", ")}.`,
1892
+ recommendation,
1893
+ };
1894
+ }
1895
+ const expectedSurfacesById = new Map(smokeConfig?.surfaces
1896
+ ?.filter((surface) => typeof surface.id === "string" && surface.id.length > 0)
1897
+ .map((surface) => [surface.id, surface]) ?? []);
1898
+ const underpopulated = surfaceResults.filter((result) => {
1899
+ const expected = expectedSurfacesById.get(result.surface);
1900
+ return expected?.expect === "populated" && result.verdict === "pass" && !runtimeSmokeStateHasPositiveCount(result.state);
1901
+ });
1902
+ if (underpopulated.length > 0) {
1903
+ return {
1904
+ passed: false,
1905
+ evidence,
1906
+ reason: `Runtime smoke evidence at ${displayPath} has passing populated surface result(s) without count > 0: ${underpopulated.map((result) => `${result.surface}:${result.state}`).join(", ")}.`,
1907
+ recommendation,
1908
+ };
1909
+ }
1910
+ }
1911
+ return {
1912
+ passed: true,
1913
+ pass_basis: "runtime-smoke",
1914
+ evidence,
1915
+ };
1916
+ }
1917
+ function runtimeSmokeCaptureRecommendationText() {
1918
+ return [
1919
+ "Declare collection surfaces in `sp-vise/smoke.json` and emit `VISE_SMOKE surface=<id> state=loading|empty|loaded count=N|error:<message>` from the mounted app's real SDK query lifecycle; `expect: \"populated\"` requires loaded count=N with N > 0.",
1920
+ "Cold-launch into the social route, wait for the visible collection to resolve, capture only the runtime log, run `vise smoke . --log <capture>`, then rerun `vise check`.",
1921
+ "Android: `adb logcat -c` before launch, then `adb logcat -d -v time | grep VISE_SMOKE > sp-vise/evidence/runtime-smoke.log`.",
1922
+ "iOS: after `simctl launch`, run `xcrun simctl spawn <device> log show --style compact --last 3m --predicate 'eventMessage CONTAINS \"VISE_SMOKE\"' > sp-vise/evidence/runtime-smoke.log`.",
1923
+ "Web/React Native/Flutter: capture console/debugPrint VISE_SMOKE lines from the real mounted app, not a standalone SDK probe.",
1924
+ ].join(" ");
1925
+ }
1926
+ function isRuntimeSmokeSurfaceResult(value) {
1927
+ if (!value || typeof value !== "object")
1928
+ return false;
1929
+ const item = value;
1930
+ return typeof item.surface === "string" && typeof item.state === "string" && typeof item.verdict === "string";
1931
+ }
1932
+ function runtimeSmokeStateHasPositiveCount(state) {
1933
+ const match = state.match(/^loaded\b[\s\S]*?\bcount\s*=\s*(\d+)/i);
1934
+ return Boolean(match && Number(match[1]) > 0);
1935
+ }
1334
1936
  function deterministicFinding(rule, findingsById) {
1335
1937
  for (const id of deterministicFindingIds(rule)) {
1336
1938
  const finding = findingsById.get(id);
@@ -1340,11 +1942,159 @@ function deterministicFinding(rule, findingsById) {
1340
1942
  }
1341
1943
  return undefined;
1342
1944
  }
1945
+ async function liveSensorContradiction(repoRoot, compliance, rule) {
1946
+ if (deterministicFindingIds(rule).length === 0) {
1947
+ return { violatorFiles: [] };
1948
+ }
1949
+ const inspection = await inspectProject(repoRoot, compliance.surface?.path === "." ? undefined : compliance.surface?.path);
1950
+ const platforms = Array.from(new Set([...inspection.platforms, ...(compliance.surface?.platforms ?? [])]));
1951
+ if (platforms.length === 0) {
1952
+ return { violatorFiles: [] };
1953
+ }
1954
+ const allFindings = await Promise.all(platforms.map((platform) => validateSetup(inspection.effectiveRoot, platform)));
1955
+ const findingsById = new Map();
1956
+ const findingsByIdAll = new Map();
1957
+ for (const finding of allFindings.flat()) {
1958
+ findingsById.set(finding.ruleId, finding);
1959
+ appendFinding(findingsByIdAll, finding.ruleId, finding);
1960
+ if (finding.sensorId) {
1961
+ findingsById.set(finding.sensorId, finding);
1962
+ appendFinding(findingsByIdAll, finding.sensorId, finding);
1963
+ }
1964
+ }
1965
+ const finding = deterministicFinding(rule, findingsById);
1966
+ if (!finding) {
1967
+ return { violatorFiles: [] };
1968
+ }
1969
+ const violatorFiles = await ruleViolatorFiles(repoRoot, sourceRootForCompliance(repoRoot, compliance), rule, findingsByIdAll);
1970
+ return {
1971
+ warning: {
1972
+ message: "The live deterministic sensor STILL reports a violation for this rule. This attestation records your override; verify the source actually satisfies the rule before relying on it.",
1973
+ sensor_finding: {
1974
+ ruleId: finding.ruleId,
1975
+ ...(finding.sensorId ? { sensorId: finding.sensorId } : {}),
1976
+ ...(finding.recommendation ? { recommendation: finding.recommendation } : {}),
1977
+ },
1978
+ },
1979
+ violatorFiles,
1980
+ };
1981
+ }
1343
1982
  function deterministicFindingIds(rule) {
1344
1983
  return (rule.enforcement.deterministic ?? [])
1345
1984
  .filter((check) => check.check === "validator-finding-absent")
1346
1985
  .map((check) => check.finding_rule_id);
1347
1986
  }
1987
+ function appendFinding(map, key, finding) {
1988
+ const existing = map.get(key);
1989
+ if (existing) {
1990
+ existing.push(finding);
1991
+ }
1992
+ else {
1993
+ map.set(key, [finding]);
1994
+ }
1995
+ }
1996
+ const LIVE_SENSOR_VIOLATOR_FIELD = "live_sensor_violator";
1997
+ export const FILE_SCOPABLE_SENSOR_SUFFIXES = new Set([
1998
+ "live-collection.api-mismatch",
1999
+ "story.live-collection",
2000
+ "search.live-collection",
2001
+ "notification-tray.live-collection",
2002
+ "event.live-collection",
2003
+ "blocked-users.live-collection",
2004
+ "invitation.live-collection",
2005
+ "membership.use-live-collection",
2006
+ "posts.status-filter-applied",
2007
+ "posts.activity-tag-filter",
2008
+ "posts.reaction-stale-post-ref",
2009
+ "poll.vote-status-guard",
2010
+ "pagination.cursor-opaque",
2011
+ "posts.parent-child-rendered",
2012
+ "comment.reference-type-enum",
2013
+ "channel.type-matches-shape",
2014
+ "reactions.configured-name-used",
2015
+ "image-post.child-resolution-awaited",
2016
+ "file-upload.via-amity-file-client",
2017
+ "user.ban-state-respected",
2018
+ "flag-count.not-leaked-to-non-mods",
2019
+ "moderation.role-gated-action",
2020
+ "custom-post-type.dataType-declared",
2021
+ "feed.target-type-explicit",
2022
+ "feed.post-datatype-handled",
2023
+ "feed.ui-states-present",
2024
+ "comments.target-resolved",
2025
+ "chat.channel-type-dm",
2026
+ "chat.deprecated-marker-sync",
2027
+ "chat.deprecated-subchannel",
2028
+ "livestream.deprecated-stream",
2029
+ "follow.status-subscription",
2030
+ "session-handler.retained",
2031
+ "client.no-ssr-init",
2032
+ "community.avatar-from-sdk",
2033
+ "feed.poll-answer-data-shape",
2034
+ "comments.query-has-limit",
2035
+ "comments.thread-ui-states-present",
2036
+ "chat.sort-explicit",
2037
+ "profile.social-counts-from-sdk",
2038
+ "community.display-name-from-sdk",
2039
+ "feed.room-post-fetched",
2040
+ "logging.no-secret-in-log",
2041
+ "logging.no-pii-in-log",
2042
+ "auth.no-literal-user-id",
2043
+ "feed.target.literal",
2044
+ ]);
2045
+ function isFileScopableRule(rule) {
2046
+ return deterministicFindingIds(rule).some((id) => [...FILE_SCOPABLE_SENSOR_SUFFIXES].some((suffix) => id === suffix || id.endsWith(`.${suffix}`)));
2047
+ }
2048
+ async function ruleViolatorFiles(repoRoot, sourceRoot, rule, findingsByIdAll) {
2049
+ const files = new Set();
2050
+ for (const id of deterministicFindingIds(rule)) {
2051
+ for (const finding of findingsByIdAll.get(id) ?? []) {
2052
+ if (!finding.file)
2053
+ continue;
2054
+ const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, finding.file);
2055
+ files.add(resolved ? resolved.relativePath : finding.file);
2056
+ }
2057
+ }
2058
+ return [...files];
2059
+ }
2060
+ async function ruleUncoveredViolators(repoRoot, sourceRoot, rule, findingsByIdAll, coveredFiles) {
2061
+ const uncovered = [];
2062
+ const seen = new Set();
2063
+ for (const id of deterministicFindingIds(rule)) {
2064
+ for (const finding of findingsByIdAll.get(id) ?? []) {
2065
+ if (!finding.file)
2066
+ continue;
2067
+ const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, finding.file);
2068
+ const normalized = resolved ? resolved.relativePath : finding.file;
2069
+ if (coveredFiles.has(normalized) || seen.has(normalized))
2070
+ continue;
2071
+ seen.add(normalized);
2072
+ uncovered.push({ ...finding, file: normalized });
2073
+ }
2074
+ }
2075
+ return uncovered;
2076
+ }
2077
+ async function collectViolatorFingerprints(repoRoot, sourceRoot, violatorFiles, evidenceFingerprints) {
2078
+ const capturedAt = new Date().toISOString();
2079
+ const alreadyFingerprinted = new Set(evidenceFingerprints.map((fp) => fp.path));
2080
+ const fingerprints = [];
2081
+ for (const file of violatorFiles) {
2082
+ const resolved = await resolveEvidenceSourcePath(repoRoot, sourceRoot, file);
2083
+ if (!resolved || alreadyFingerprinted.has(resolved.relativePath)) {
2084
+ continue;
2085
+ }
2086
+ alreadyFingerprinted.add(resolved.relativePath);
2087
+ const fileStat = await stat(resolved.absolutePath);
2088
+ fingerprints.push({
2089
+ evidence_field: LIVE_SENSOR_VIOLATOR_FIELD,
2090
+ path: resolved.relativePath,
2091
+ sha256: await digestFile(resolved.absolutePath),
2092
+ size_bytes: fileStat.size,
2093
+ captured_at: capturedAt,
2094
+ });
2095
+ }
2096
+ return fingerprints;
2097
+ }
1348
2098
  function uniqueStrings(values) {
1349
2099
  const seen = new Set();
1350
2100
  const result = [];
@@ -1357,7 +2107,7 @@ function uniqueStrings(values) {
1357
2107
  }
1358
2108
  return result;
1359
2109
  }
1360
- function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = []) {
2110
+ function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = [], fileScoped = false) {
1361
2111
  const ref = compliance.rules.find((item) => item.rule_id === rule.id);
1362
2112
  if (!ref) {
1363
2113
  throw new Error(`Rule is not in compliance contract: ${rule.id}`);
@@ -1379,6 +2129,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
1379
2129
  signature: null,
1380
2130
  evidence,
1381
2131
  source_fingerprints: sourceFingerprints,
2132
+ ...(fileScoped ? { file_scoped: true } : {}),
1382
2133
  rationale_for_attestation: rationale,
1383
2134
  attested_at: new Date().toISOString(),
1384
2135
  };
@@ -1527,13 +2278,19 @@ export function ruleSummary(rule) {
1527
2278
  pattern: "pattern" in blocker ? blocker.pattern : undefined,
1528
2279
  reason: blocker.reason,
1529
2280
  })),
1530
- deterministic_check_ids: (rule.enforcement.deterministic ?? []).map((check) => check.finding_rule_id),
2281
+ deterministic_check_ids: (rule.enforcement.deterministic ?? []).map(deterministicCheckId),
1531
2282
  attestation_allowed: rule.enforcement.attestation.allowed,
1532
2283
  host_agent_min_confidence: rule.enforcement.attestation.host_agent_min_confidence,
1533
2284
  human_allowed: rule.enforcement.attestation.human_allowed !== false,
1534
2285
  evidence_field_names: (rule.enforcement.attestation.evidence_required ?? []).map((field) => field.field),
1535
2286
  };
1536
2287
  }
2288
+ function deterministicCheckId(check) {
2289
+ if (check.check === "validator-finding-absent") {
2290
+ return check.finding_rule_id;
2291
+ }
2292
+ return `${check.check}:${check.path ?? "sp-vise/evidence/runtime-smoke.json"}`;
2293
+ }
1537
2294
  export async function runBlockers(rule, root) {
1538
2295
  const fired = [];
1539
2296
  for (const blocker of rule.enforcement.blockers ?? []) {
@@ -1616,16 +2373,78 @@ async function readAttestations(repoRoot) {
1616
2373
  if (!entry.endsWith(".json")) {
1617
2374
  continue;
1618
2375
  }
1619
- const attestation = await readJsonIfExists(path.join(dir, entry));
1620
- if (attestation?.rule_id && attestation.payload_hash) {
1621
- const { payload_hash, ...withoutHash } = attestation;
1622
- if (digestJson(withoutHash) === payload_hash) {
1623
- result.set(attestation.rule_id, attestation);
1624
- }
2376
+ const candidate = await readAttestationCandidate(path.join(dir, entry));
2377
+ if (candidate.status === "valid") {
2378
+ result.set(candidate.attestation.rule_id, candidate.attestation);
1625
2379
  }
1626
2380
  }
1627
2381
  return result;
1628
2382
  }
2383
+ async function readAttestationHygiene(repoRoot, compliance) {
2384
+ const dir = attestationsDir(repoRoot);
2385
+ const activeRuleIds = new Set(compliance.rules.map((rule) => rule.rule_id));
2386
+ const ignored = [];
2387
+ const invalid = [];
2388
+ let entries = [];
2389
+ try {
2390
+ entries = await readdir(dir);
2391
+ }
2392
+ catch {
2393
+ return undefined;
2394
+ }
2395
+ for (const entry of entries) {
2396
+ if (!entry.endsWith(".json")) {
2397
+ continue;
2398
+ }
2399
+ const rel = path.posix.join(complianceDirName, attestationsDirName, entry);
2400
+ const candidate = await readAttestationCandidate(path.join(dir, entry));
2401
+ if (candidate.status === "invalid") {
2402
+ invalid.push({ file: rel, ...(candidate.rule_id ? { rule_id: candidate.rule_id } : {}), reason: candidate.reason });
2403
+ continue;
2404
+ }
2405
+ if (!activeRuleIds.has(candidate.attestation.rule_id)) {
2406
+ ignored.push({
2407
+ file: rel,
2408
+ rule_id: candidate.attestation.rule_id,
2409
+ 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.",
2410
+ });
2411
+ }
2412
+ }
2413
+ if (ignored.length === 0 && invalid.length === 0) {
2414
+ return undefined;
2415
+ }
2416
+ return {
2417
+ status: "needs-review",
2418
+ ignored,
2419
+ invalid,
2420
+ 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.",
2421
+ };
2422
+ }
2423
+ async function readAttestationCandidate(filePath) {
2424
+ let parsed;
2425
+ try {
2426
+ parsed = JSON.parse(await readFile(filePath, "utf8"));
2427
+ }
2428
+ catch {
2429
+ return { status: "invalid", reason: "Attestation file is not valid JSON." };
2430
+ }
2431
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2432
+ return { status: "invalid", reason: "Attestation file is not a JSON object." };
2433
+ }
2434
+ const attestation = parsed;
2435
+ const ruleId = typeof attestation.rule_id === "string" ? attestation.rule_id : undefined;
2436
+ if (!ruleId) {
2437
+ return { status: "invalid", reason: "Attestation file is missing rule_id." };
2438
+ }
2439
+ if (typeof attestation.payload_hash !== "string" || attestation.payload_hash.length === 0) {
2440
+ return { status: "invalid", rule_id: ruleId, reason: "Attestation file is missing payload_hash." };
2441
+ }
2442
+ const { payload_hash, ...withoutHash } = attestation;
2443
+ if (digestJson(withoutHash) !== payload_hash) {
2444
+ return { status: "invalid", rule_id: ruleId, reason: "Attestation payload_hash does not match the file contents." };
2445
+ }
2446
+ return { status: "valid", attestation };
2447
+ }
1629
2448
  async function readJsonIfExists(filePath) {
1630
2449
  try {
1631
2450
  if (!(await stat(filePath)).isFile()) {
@@ -1669,15 +2488,77 @@ function sidecarReadme(compliance) {
1669
2488
  "",
1670
2489
  ].join("\n");
1671
2490
  }
1672
- function sidecarDir(repoRoot) {
1673
- return path.join(repoRoot, complianceDirName);
1674
- }
1675
2491
  function attestationsDir(repoRoot) {
1676
2492
  return path.join(sidecarDir(repoRoot), attestationsDirName);
1677
2493
  }
1678
2494
  function compliancePath(repoRoot) {
1679
2495
  return path.join(sidecarDir(repoRoot), "compliance.json");
1680
2496
  }
2497
+ const BASELINE_NON_GREEN = new Set(["deterministic-fail", "attestation-needed", "stale", "blocked"]);
2498
+ function baselineKeyFor(result) {
2499
+ const ruleId = result.contractRuleId ?? result.ruleId;
2500
+ return `${ruleId}@${result.finding?.file ?? "*"}`;
2501
+ }
2502
+ function baselinePath(repoRoot) {
2503
+ return path.join(sidecarDir(repoRoot), "baseline.json");
2504
+ }
2505
+ async function readBaseline(repoRoot) {
2506
+ return readJsonIfExists(baselinePath(repoRoot));
2507
+ }
2508
+ function deriveGate(flags) {
2509
+ if (flags.hasBlocked)
2510
+ return { status: "blocked", exitCode: 3 };
2511
+ if (flags.hasDeterministicFailure)
2512
+ return { status: "deterministic-failures", exitCode: 2 };
2513
+ if (flags.needsAttestation)
2514
+ return { status: "needs-attestation", exitCode: 1 };
2515
+ if (flags.hasCompletenessGap)
2516
+ return { status: "completeness-gap", exitCode: 5 };
2517
+ if (flags.hasSelectedOptionalFailures)
2518
+ return { status: "selected-capability-failures", exitCode: 6 };
2519
+ return { status: "green", exitCode: 0 };
2520
+ }
2521
+ export async function recordBaseline(repoPath) {
2522
+ const repoRoot = path.resolve(repoPath);
2523
+ const compliance = await readCompliance(repoRoot);
2524
+ const check = await checkCompliance(repoPath);
2525
+ if (check.status === "contract-drift") {
2526
+ throw new Error("Cannot record a baseline while the contract is drifted (run `vise init` to refresh the sidecar first).");
2527
+ }
2528
+ const findings = {};
2529
+ for (const result of check.rules) {
2530
+ if (!BASELINE_NON_GREEN.has(result.status))
2531
+ continue;
2532
+ const key = baselineKeyFor(result);
2533
+ findings[key] = (findings[key] ?? 0) + 1;
2534
+ }
2535
+ const completeness_missing = (check.completeness?.missing ?? []).map((item) => item.id);
2536
+ const selected_optional = [
2537
+ ...(check.selectedOptionalCapabilities?.failed ?? []).map((item) => item.id),
2538
+ ...(check.selectedOptionalCapabilities?.unknown ?? []),
2539
+ ];
2540
+ const baseline = {
2541
+ baselined_at: new Date().toISOString(),
2542
+ vise_version: packageVersion,
2543
+ outcome: compliance.outcome,
2544
+ ruleset_digest: compliance.ruleset_digest,
2545
+ findings,
2546
+ completeness_missing,
2547
+ selected_optional,
2548
+ };
2549
+ await writeJson(baselinePath(repoRoot), baseline);
2550
+ return {
2551
+ status: "baselined",
2552
+ baseline: complianceDirName + "/baseline.json",
2553
+ outcome: baseline.outcome,
2554
+ recorded: {
2555
+ findings: Object.values(findings).reduce((a, b) => a + b, 0),
2556
+ completeness_missing: completeness_missing.length,
2557
+ selected_optional: selected_optional.length,
2558
+ },
2559
+ 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.",
2560
+ };
2561
+ }
1681
2562
  function experienceReportPath(repoRoot) {
1682
2563
  return path.join(sidecarDir(repoRoot), experienceReportFileName);
1683
2564
  }