@amityco/social-plus-vise 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to `@amityco/social-plus-vise` are documented in this file.
4
4
 
5
5
  The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.7.0 — 2026-07-09
8
+
9
+ ### Added
10
+ - **Day-2 governance: adding features to an app that already uses social.plus is now a first-class, governed flow.**
11
+ - **Brownfield adoption honesty.** `vise init` on a repo with pre-existing non-green findings surfaces a `brownfield` hint: record `vise baseline .` before writing code, then gate with `vise check --new-only`. A baseline excludes pre-existing *rule findings only* — the requested outcome's completeness checklist and selected capabilities are never baselined, so a baselined repo can't go green without the feature actually built. Baseline files recorded by older versions that carried deliverable entries are ignored and disclosed (`ignored_legacy_deliverable_entries`).
12
+ - **Engagement ledger.** Initializing a different outcome records the outgoing engagement's final check, contract, and intake to `sp-vise/engagements/<outcome>/` (latest per outcome) and discloses the switch in the init result. `vise status` lists superseded engagements under `previousEngagements`. Replacing a **green** engagement asks for an explicit `--supersede-engagement`.
13
+ - **Re-verification of finished work.** `vise check --engagement <outcome>` re-verdicts one recorded engagement against the current rules and code (exit `11` on drift, `0` when it holds; `ruleset_upgraded` disclosed). `vise check --all-engagements` runs the normal gate plus re-verdicts of every recorded engagement — active gate non-green keeps its own exit code; active green with a drifted recorded engagement exits `11` (`engagement-drift`). Findings already gating the active engagement are counted once there (`shared_with_active`).
14
+ - **Attestations are bound to their engagement.** `vise attest` stamps the active outcome; after an outcome switch, a rule-global attestation re-gates (`stale_engagement_attestation`) until re-verified — file-scoped attestations survive the switch because new files re-gate them anyway. Attestations recorded by older versions keep their historical behavior.
15
+ - **Completed journeys don't dead-end new requests.** After a finished multi-surface journey, `vise init` for a genuinely new outcome now proceeds (recording the completed journey to the ledger) instead of stopping at `workplan-complete`; re-initializing a surface the journey already completed still gets the honest stop.
16
+ - New skill section — "Adding Features To An App That Already Uses social.plus" — teaches agents both variants (hand-rolled integration and prior governed engagement).
17
+ - **SDK facts: broader capability coverage and deeper model grounding.** `vise sdk-facts` (now listed in the public `vise --help`) gains capability anchors for `community`, `chat`, `livestream` (Room API only), `notification-tray`, and `events` on TypeScript, plus per-platform anchor sets with Flutter coverage for six families (`comments`, `reactions`, `posts`, `users`, `community`, `chat`) — a partially anchored platform's result names its coverage explicitly. Field-level model schemas grew from 6 to 14 TypeScript models (`Amity.Community`, `Amity.Channel`, `Amity.Message`, `Amity.SubChannel`, `Amity.Room`, `Amity.NotificationTrayItem`, `Amity.NotificationTraySeen`, `Amity.Event`), all extraction-grounded with real field names and types.
18
+ - **Development-workspace guard.** Customer-integration commands now recognize the Vise / social.plus blocks development workspace itself: `vise init` refuses with `status: "internal-workspace"` (exit `10`, `--allow-internal-workspace` to override for harness runs), and `inspect`/`plan` attach an advisory warning — so the projectless `vise sdk-facts` persona and the customer persona can't be mixed up silently.
19
+
20
+ ### Fixed
21
+ - **Blocks installer on native projects.** `vise blocks plan`/`add`/`validate` pointed at a project without a package manifest (an iOS or Android app) now return the graceful `needs-review` plan with unsupported-platform stop conditions instead of crashing on a missing `package.json`.
22
+
23
+ ### Compatibility
24
+ - **Additive exit codes.** `10` (`internal-workspace`, init-only) and `11` (`engagement-drift`, only under the new `--engagement`/`--all-engagements` flags). The existing 0–9 exit surface and precedence are unchanged; default `vise check` behavior is unchanged, so pipelines gating on `--ci` or `exitCode === 0` are unaffected.
25
+ - **One intentional init behavior change.** `vise init` replacing a **green** engagement with a different outcome now requires `--supersede-engagement` (exit `7` otherwise); red or in-progress engagements switch as before, and the outgoing engagement's final state is recorded to the ledger either way.
26
+ - **Sidecar additions are additive.** `sp-vise/engagements/` and the new result fields (`brownfield`, `engagement_switch`, `previousEngagements`, `stale_engagement_attestation`) are new; no existing file changes shape. Legacy baselines and attestations continue to parse — with the honesty adjustments disclosed above.
27
+
7
28
  ## 1.6.0 — 2026-07-06
8
29
 
9
30
  ### Added
package/README.md CHANGED
@@ -206,7 +206,7 @@ Run `vise <command> --help` for full flags. JSON output is the default for agent
206
206
  | `vise plan [path] --request "..." [--summary]` | Grounded implementation plan with intake questions and docs citations; `--summary` prints a compact route/intake view |
207
207
  | `vise plan --summary "..."` | Shortcut for quick routing or discovery when the current directory is the repo |
208
208
  | `vise plan-harness [path] --request "..."` | Pre-planning step: build the harness around the request |
209
- | `vise init [path] --request "..." [--answer key=value]` | Write the `sp-vise/` compliance contract once blocking intake is answered; exits 7 (`needs-clarification`) otherwise |
209
+ | `vise init [path] --request "..." [--answer key=value]` | Write the `sp-vise/` compliance contract once blocking intake is answered; exits 7 (`needs-clarification`) otherwise. Refuses to initialize the Vise / social.plus blocks development workspace itself (`status: "internal-workspace"`, exit 10 — use `vise sdk-facts` there, or `--allow-internal-workspace` for an intentional harness run). Replacing a **green** engagement with a different outcome requires `--supersede-engagement` (exit 7 otherwise); the outgoing engagement's final state is recorded to `sp-vise/engagements/` either way |
210
210
  | `vise workplan next [path] --request "..."` | For broad social requests: print the next uncompleted surface and its focused commands |
211
211
  | `vise workplan status [path] --request "..."` | Show the workplan sequence and completed surfaces; ignores unverifiable or drifted snapshot evidence. If the in-scope surface's live `vise check` is already green, auto-records that one surface (same evidence bar as `workplan complete`) so a working, check-green surface isn't left uncounted |
212
212
  | `vise workplan complete [path] --request "..." --surface <id>` | Record a green-checked surface; snapshots evidence plus an integrity manifest under `sp-vise/workplan-snapshots/<surface>/` |
@@ -255,11 +255,13 @@ Everything in this group is local and advisory: no uploads, no `vise check` exit
255
255
  | `vise check [path]` | Re-validate against the recorded contract: `green`, `needs-attestation`, `deterministic-failures`, `blocked`, `contract-drift`, `completeness-gap`, `selected-capability-failures`, `no-platform` (no SDK platform detected — reported as a failure, not a vacuous green), or `runtime-proof-waived` |
256
256
  | `vise check [path] --ci` | Read-only variant that exits non-zero unless green (for CI) |
257
257
  | `vise check [path] --allow-proof-waiver` | Accept an honest `runtime-proof-waived` result (from `vise smoke waive`) as passing (exit 0) instead of blocking; without it a waiver blocks CI by default |
258
- | `vise check [path] --new-only` | **Brownfield gate.** With a recorded baseline, gate only on findings introduced *since* the baseline (pre-existing ones are reported but excluded). Default `check` always gates on everything |
259
- | `vise baseline [path]` | Snapshot the current pre-existing findings to `sp-vise/baseline.json` so `check --new-only` can separate legacy debt from new gaps. `vise init --baseline` records it at init time |
258
+ | `vise check [path] --new-only` | **Brownfield gate.** With a recorded baseline, gate only on rule findings introduced *since* the baseline (pre-existing ones are reported but excluded). The outcome's completeness checklist and selected optional capabilities are **never** excluded — they are the engagement's deliverables and gate like a default check. Default `check` always gates on everything |
259
+ | `vise check [path] --engagement <outcome>` | **Re-verdict one recorded engagement** (from `sp-vise/engagements/`) against the current ruleset and code. Exit `11` on drift, `0` when it still holds; never the active engagement's codes. `ruleset_upgraded` discloses when the installed ruleset moved since it was recorded |
260
+ | `vise check [path] --all-engagements` | Run the normal active gate **plus** re-verdicts of every recorded engagement. Active gate non-green → its normal exit code; active green but a recorded engagement drifted → exit `11` (`engagement-drift`). Findings already gating the active engagement are counted once there (`shared_with_active`) |
261
+ | `vise baseline [path]` | Snapshot the current pre-existing rule findings to `sp-vise/baseline.json` so `check --new-only` can separate legacy debt from new gaps. Deliverables (completeness/selected capabilities) are never baselined. Record it right after `vise init`, before writing code — a mid-build baseline would swallow your own findings. `vise init --baseline` records it at init time |
260
262
  | `vise validate [path]` | Run the deterministic validators only (no attestation comparison) |
261
263
  | `vise sync [path]` | Persist deterministic-pass evidence to `sp-vise/attestations/` |
262
- | `vise attest [path] --rule <id> --signer host-agent --confidence high --evidence-file evidence.json --rationale "..."` | Record an attestation when a rule passes through architecture the deterministic check can't see |
264
+ | `vise attest [path] --rule <id> --signer host-agent --confidence high --evidence-file evidence.json --rationale "..."` | Record an attestation when a rule passes through architecture the deterministic check can't see. Attestations are bound to the engagement outcome they were reviewed under: after `vise init` switches the sidecar to a different outcome, a rule-global attestation re-gates (`stale_engagement_attestation`) until re-attested — file-scoped attestations survive the switch because new files re-gate them anyway |
263
265
  | `vise explain <ruleId>` | Print a rule's rationale, evidence requirements, and remediation |
264
266
  | `vise status [path]` | Summarize the current compliance state |
265
267
 
@@ -330,6 +332,9 @@ jobs:
330
332
  | `4` | Contract drift — recorded rules no longer match the current ruleset |
331
333
  | `5` | Baseline capability neither implemented nor opted-out (add it or place `// vise: scope-omit <id> — <reason>`) |
332
334
  | `6` | An explicitly selected optional capability failed its source sensors |
335
+ | `8` | No SDK platform detected (`no-platform`) — reported as a failure, never a vacuous green |
336
+ | `9` | Runtime proof waived (`runtime-proof-waived`) — accept explicitly with `--allow-proof-waiver` |
337
+ | `11` | Engagement drift (`--engagement` / `--all-engagements` only): the active gate is green but a recorded engagement re-verdicts non-green — a previously finished surface no longer holds |
333
338
 
334
339
  ## Compliance Contract
335
340
 
@@ -341,6 +346,7 @@ Vise writes local planning, compliance, design, and evidence artifacts under `sp
341
346
  | `sp-vise/intake.json` | `vise init` | Request, outcome, intake answers, design-review status |
342
347
  | `sp-vise/inspection.json` | `vise init` | Platform, surface, and design signals detected at init |
343
348
  | `sp-vise/attestations/*.json` | `vise sync` / `vise attest` | Per-rule evidence: signer, confidence, rationale, source fingerprints for drift detection |
349
+ | `sp-vise/engagements/<outcome>/*.json` | `vise init` (on an outcome switch) | Engagement ledger: the superseded engagement's final check, contract, and intake (latest per outcome) — surfaced by `vise status` as `previousEngagements` |
344
350
  | `sp-vise/creative-brief.json` + `creative-brief.md` | `vise creative` | Advisory brief: goals, archetypes, candidate variants (JSON + human-readable) |
345
351
  | `sp-vise/candidate-ranking-preview.json` | `vise creative --ranking-preview` | Opt-in local ranking preview for review context; `experience_score: null`, no uploads, no default-order change, no top-1 confidence claim |
346
352
  | `sp-vise/creative-selection.json` | `vise creative accept` | Accepted variant and plan/workplan feed-forward context |
package/dist/flow.js CHANGED
@@ -2,6 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { sidecarDir } from "./sidecar.js";
5
+ export function completedWorkplanBlocksOutcome(stages, requestedOutcome) {
6
+ if (!requestedOutcome || requestedOutcome === "unknown") {
7
+ return true;
8
+ }
9
+ return stages.some((stage) => stage.outcome === requestedOutcome);
10
+ }
5
11
  export function blueprintDigest(input) {
6
12
  const canonical = JSON.stringify({
7
13
  path: input.path ?? null,
package/dist/server.js CHANGED
@@ -5,18 +5,19 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { sidecarDir as sidecarPath } from "./sidecar.js";
8
- import { blueprintDigest, readFlowState, readOmittedSurfaces, renderFlowBlueprint, runtimeReadinessForStages, writeFlowBlueprintHtml, writeFlowState, } from "./flow.js";
8
+ import { blueprintDigest, completedWorkplanBlocksOutcome, readFlowState, readOmittedSurfaces, renderFlowBlueprint, runtimeReadinessForStages, writeFlowBlueprintHtml, writeFlowState, } from "./flow.js";
9
9
  import { normalizeSolutionPathAnswer } from "./solutionPath.js";
10
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
- import { attestRule, attestRuleTool, checkCompliance, checkComplianceTool, explainRule, explainRuleTool, experienceReportTool, initCompliance, initComplianceTool, recordBaseline, initEngagement, initEngagementTool, listRules, showEngagement, showEngagementTool, statusCompliance, syncCompliance, syncComplianceTool, } from "./tools/compliance.js";
13
+ import { attestRule, attestRuleTool, checkAllEngagements, checkCompliance, checkEngagementReVerdict, checkComplianceTool, explainRule, explainRuleTool, experienceReportTool, initCompliance, initComplianceTool, recordBaseline, initEngagement, initEngagementTool, listRules, showEngagement, showEngagementTool, statusCompliance, syncCompliance, syncComplianceTool, } from "./tools/compliance.js";
14
14
  import { designCheckTool, designContrastTool, designExtractTool, designInitTokensTool, designPreviewTool, designReferenceTool } from "./tools/design.js";
15
15
  import { getDocPageTool, searchDocsTool } from "./tools/docs.js";
16
16
  import { compileExperienceTool } from "./tools/experienceCompiler.js";
17
17
  import { experienceSensorsTool } from "./tools/experienceSensors.js";
18
18
  import { planHarnessTool } from "./tools/harness.js";
19
19
  import { planIntegrationTool } from "./tools/integration.js";
20
+ import { detectInternalWorkspace, internalWorkspaceWarning } from "./tools/internalWorkspace.js";
20
21
  import { recordLearningTool, showLearningTool } from "./tools/learning.js";
21
22
  import { inspectProjectTool, validateSetupTool } from "./tools/project.js";
22
23
  import { resolveRequestTool, suggestPatchTool } from "./tools/resolve.js";
@@ -153,10 +154,17 @@ async function handleCli(args) {
153
154
  return "exit";
154
155
  }
155
156
  if (command === "inspect") {
156
- await printToolResult(inspectProjectTool, {
157
- repoPath: positionalRepoPath(args.slice(1)),
157
+ const inspectRepo = positionalRepoPath(args.slice(1));
158
+ const inspectResult = await inspectProjectTool.call({
159
+ repoPath: inspectRepo,
158
160
  surfacePath: flagValue(args, "surface") ?? flagValue(args, "surface-path"),
159
161
  });
162
+ const inspectPayload = JSON.parse(inspectResult.content.map((item) => item.text).join("\n"));
163
+ const inspectWorkspace = await detectInternalWorkspace(inspectRepo);
164
+ if (inspectWorkspace.isInternalWorkspace) {
165
+ inspectPayload.internalWorkspaceWarning = internalWorkspaceWarning(inspectWorkspace);
166
+ }
167
+ console.log(JSON.stringify(inspectPayload, null, 2));
160
168
  return "exit";
161
169
  }
162
170
  if (command === "creative") {
@@ -300,6 +308,10 @@ async function handleCli(args) {
300
308
  const result = await planIntegrationTool.call(input);
301
309
  const text = result.content.map((item) => item.text).join("\n");
302
310
  const payload = JSON.parse(text);
311
+ const planWorkspace = await detectInternalWorkspace(positionalRepoPath(args.slice(1)));
312
+ if (planWorkspace.isInternalWorkspace) {
313
+ payload.internalWorkspaceWarning = internalWorkspaceWarning(planWorkspace);
314
+ }
303
315
  emitResult("plan", hasFlag(args, "summary") ? planSummary(payload) : payload, args);
304
316
  return "exit";
305
317
  }
@@ -462,9 +474,19 @@ async function handleCli(args) {
462
474
  return "exit";
463
475
  }
464
476
  if (command === "init") {
465
- assertOnlyKnownFlags(args, ["request", "surface", "surface-path", "answer", "allow-unresolved-intake", "baseline"], "init");
477
+ assertOnlyKnownFlags(args, ["request", "surface", "surface-path", "answer", "allow-unresolved-intake", "baseline", "allow-internal-workspace", "supersede-engagement"], "init");
466
478
  const initRepo = positionalRepoPath(args.slice(1));
467
479
  const initRequest = requiredFlagValue(args, "request", "init requires --request.");
480
+ const initWorkspace = await detectInternalWorkspace(initRepo);
481
+ if (initWorkspace.isInternalWorkspace && !hasFlag(args, "allow-internal-workspace")) {
482
+ console.log(JSON.stringify({
483
+ status: "internal-workspace",
484
+ ...internalWorkspaceWarning(initWorkspace),
485
+ instruction: "Refusing to initialize the compliance sidecar here. If this really is intentional (e.g. a harness run at the workspace root), re-run with --allow-internal-workspace.",
486
+ }, null, 2));
487
+ process.exitCode = 10;
488
+ return "exit";
489
+ }
468
490
  const initWorkplanPlan = await workplanPlan(initRepo, initRequest, args);
469
491
  const { sequence: initSequence, omitted: initOmitted, canonicalRequest: initCanonicalRequest } = await activeBlueprint(initRepo, initRequest, initWorkplanPlan);
470
492
  if (initSequence.length >= 2 && initWorkplanPlan.platform !== "unknown") {
@@ -474,15 +496,16 @@ async function handleCli(args) {
474
496
  process.exitCode = 7;
475
497
  return "exit";
476
498
  }
477
- const workplanInitGate = await requireNextWorkplanSurfaceInit(initRepo, initCanonicalRequest, initSequence, keyValueFlag(args, "answer"), initOmitted, initRequest);
499
+ const workplanInitGate = await requireNextWorkplanSurfaceInit(initRepo, initCanonicalRequest, initSequence, keyValueFlag(args, "answer"), initOmitted, initRequest, initWorkplanPlan.outcome);
478
500
  if (workplanInitGate) {
479
501
  console.log(JSON.stringify(workplanInitGate, null, 2));
480
502
  process.exitCode = 7;
481
503
  return "exit";
482
504
  }
483
505
  }
484
- const result = await initCompliance(initRepo, initRequest, flagValue(args, "surface") ?? flagValue(args, "surface-path"), keyValueFlag(args, "answer"), { allowUnresolvedIntake: hasFlag(args, "allow-unresolved-intake") });
485
- if (result.status === "needs-clarification" && typeof result.exitCode === "number") {
506
+ const result = await initCompliance(initRepo, initRequest, flagValue(args, "surface") ?? flagValue(args, "surface-path"), keyValueFlag(args, "answer"), { allowUnresolvedIntake: hasFlag(args, "allow-unresolved-intake"), baselinePlanned: hasFlag(args, "baseline"), supersedeEngagement: hasFlag(args, "supersede-engagement") });
507
+ if ((result.status === "needs-clarification" || result.status === "needs-engagement-supersede-confirmation") &&
508
+ typeof result.exitCode === "number") {
486
509
  process.exitCode = result.exitCode;
487
510
  }
488
511
  if (hasFlag(args, "baseline") && result.status === "initialized") {
@@ -492,7 +515,26 @@ async function handleCli(args) {
492
515
  return "exit";
493
516
  }
494
517
  if (command === "check") {
495
- assertOnlyKnownFlags(args, ["ci", "format", "new-only", "allow-proof-waiver"], "check");
518
+ assertOnlyKnownFlags(args, ["ci", "format", "new-only", "allow-proof-waiver", "engagement", "all-engagements"], "check");
519
+ const engagementOutcome = flagValue(args, "engagement");
520
+ if (engagementOutcome) {
521
+ if (hasFlag(args, "new-only") || hasFlag(args, "ci") || hasFlag(args, "all-engagements")) {
522
+ throw new Error("--engagement re-verdicts one recorded engagement and cannot combine with --new-only, --ci, or --all-engagements.");
523
+ }
524
+ const reVerdict = await checkEngagementReVerdict(positionalRepoPath(args.slice(1)), engagementOutcome);
525
+ emitResult("check", reVerdict, args);
526
+ process.exitCode = typeof reVerdict.exitCode === "number" ? reVerdict.exitCode : 0;
527
+ return "exit";
528
+ }
529
+ if (hasFlag(args, "all-engagements")) {
530
+ if (hasFlag(args, "ci")) {
531
+ throw new Error("--all-engagements cannot combine with --ci (the CI shape covers the active engagement only).");
532
+ }
533
+ const combined = await checkAllEngagements(positionalRepoPath(args.slice(1)), { newOnly: hasFlag(args, "new-only"), allowProofWaiver: hasFlag(args, "allow-proof-waiver") });
534
+ emitResult("check", combined, args);
535
+ process.exitCode = typeof combined.exitCode === "number" ? combined.exitCode : 0;
536
+ return "exit";
537
+ }
496
538
  const result = await checkCompliance(positionalRepoPath(args.slice(1)), { newOnly: hasFlag(args, "new-only"), allowProofWaiver: hasFlag(args, "allow-proof-waiver") });
497
539
  emitResult("check", hasFlag(args, "ci") ? ciCheckResult(result) : result, args);
498
540
  process.exitCode = result.exitCode;
@@ -657,7 +699,6 @@ function internalCommandsSection() {
657
699
  return `
658
700
 
659
701
  Internal commands (not part of the public surface — see docs/TOOLS.md):
660
- vise sdk-facts --platform <p> [--capability <c>] Read bundled SDK surface facts. Projectless, read-only.
661
702
  vise blocks <list|plan|add> ... Install and validate social.plus blocks in customer projects.
662
703
  vise resolve --request "..." Resolve a request into the closest supported Vise outcome.
663
704
  vise skill-path Print the bundled skill source path and install targets.
@@ -1105,6 +1146,7 @@ Usage:
1105
1146
  vise learning show [repoPath] Show local learning summary
1106
1147
  vise debug [repoPath] --error ... Debug an SDK-specific runtime error and emit a repair brief
1107
1148
  vise explore "<request>" Discover what social.plus offers for a request (no project/credentials needed)
1149
+ vise sdk-facts --platform <p> Read proven SDK surface facts (symbols, capabilities, model schemas). Projectless, read-only
1108
1150
  vise plan [repoPath] --request "..." Create an implementation plan
1109
1151
  vise workplan next [repoPath] --request "..." Get the next broad-social surface to implement
1110
1152
  vise init [repoPath] --request "..." Initialize compliance sidecar (add --baseline on a brownfield app)
@@ -2186,13 +2228,16 @@ function socialWorkplanSequence(plan) {
2186
2228
  }
2187
2229
  return workplan.sequence;
2188
2230
  }
2189
- async function requireNextWorkplanSurfaceInit(repoRoot, request, sequence, answers, omitted = [], requestedAs) {
2231
+ async function requireNextWorkplanSurfaceInit(repoRoot, request, sequence, answers, omitted = [], requestedAs, requestedOutcome) {
2190
2232
  const progress = await verifiedWorkplanProgress(repoRoot, request, sequence);
2191
2233
  const completedIds = new Set(progress.completed.map((item) => item.surface));
2192
2234
  const nextSurface = sequence.find((surface) => !completedIds.has(surface.id));
2193
2235
  const selectedSurface = resolveWorkplanSurfaceAnswer(sequence, answers.feature_surface);
2194
2236
  const selectedOmittedSurface = resolveOmittedSurfaceAnswer(omitted, answers.feature_surface);
2195
2237
  if (!nextSurface) {
2238
+ if (!completedWorkplanBlocksOutcome(sequence, requestedOutcome)) {
2239
+ return null;
2240
+ }
2196
2241
  return {
2197
2242
  status: "workplan-complete",
2198
2243
  exitCode: 7,
@@ -242,7 +242,7 @@ async function packageChangeFor(root, platform, packageInfo, packageSource) {
242
242
  };
243
243
  }
244
244
  const file = "package.json";
245
- const packageJson = await readPackageJson(path.join(root, file));
245
+ const packageJson = await readPackageJson(path.join(root, file)).catch(() => ({}));
246
246
  return {
247
247
  file,
248
248
  dependency: packageInfo.dependencyName,
@@ -416,6 +416,22 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
416
416
  "The gate still blocks until every id above is answered; pass --allow-unresolved-intake only for retrospective/harness initialization.",
417
417
  };
418
418
  }
419
+ const previousCompliance = await readJsonIfExists(compliancePath(repoRoot));
420
+ let engagementSwitch;
421
+ if (previousCompliance && previousCompliance.outcome !== outcome) {
422
+ const finalCheck = await checkCompliance(repoPath).catch(() => undefined);
423
+ if (finalCheck?.status === "green" && !options.supersedeEngagement) {
424
+ return {
425
+ status: "needs-engagement-supersede-confirmation",
426
+ exitCode: 7,
427
+ from: previousCompliance.outcome,
428
+ to: outcome,
429
+ 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\`).`,
430
+ instruction: `Re-run with --supersede-engagement to proceed. The green final state will be recorded in sp-vise/engagements/${previousCompliance.outcome}/ first.`,
431
+ };
432
+ }
433
+ engagementSwitch = await recordEngagementOnSwitch(repoRoot, previousCompliance, outcome, finalCheck);
434
+ }
419
435
  const compliance = {
420
436
  schema_version: schemaVersion,
421
437
  vise_version: packageVersion,
@@ -468,6 +484,17 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
468
484
  if (engagement && engagement.scope.outcomes.length > 0 && !engagement.scope.outcomes.includes(outcome)) {
469
485
  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
486
  }
487
+ const preExistingFindingFiles = [
488
+ ...new Set(checkSnapshot.rules
489
+ .filter((rule) => BASELINE_NON_GREEN.has(rule.status) && rule.finding?.file)
490
+ .map((rule) => rule.finding?.file)),
491
+ ];
492
+ const brownfieldHint = !options.baselinePlanned && preExistingFindingFiles.length > 0 && !(await readBaseline(repoRoot))
493
+ ? {
494
+ pre_existing_finding_files: preExistingFindingFiles.slice(0, 5),
495
+ 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.",
496
+ }
497
+ : undefined;
471
498
  return {
472
499
  status: "initialized",
473
500
  sidecar: complianceDirName,
@@ -480,6 +507,8 @@ export async function initCompliance(repoPath, request, surfacePath, answers = {
480
507
  ...(compliance.design_contract && { design_contract: compliance.design_contract }),
481
508
  ...(selectedOptionalCapabilities.length > 0 && { selected_optional_capabilities: selectedOptionalCapabilities }),
482
509
  ...(runtimeSmokeTemplate ? { runtime_smoke: runtimeSmokeTemplate } : {}),
510
+ ...(brownfieldHint ? { brownfield: brownfieldHint } : {}),
511
+ ...(engagementSwitch ? { engagement_switch: engagementSwitch } : {}),
483
512
  intake: {
484
513
  status: intake.status,
485
514
  remainingBlocking: intake.remainingBlocking,
@@ -862,7 +891,7 @@ async function ruleFreshness(rule) {
862
891
  }
863
892
  export async function checkCompliance(repoPath, options = {}) {
864
893
  const repoRoot = path.resolve(repoPath);
865
- const compliance = await readCompliance(repoRoot);
894
+ const compliance = options.contract ?? (await readCompliance(repoRoot));
866
895
  const rules = await rulesById();
867
896
  const drift = contractDrift(compliance, rules);
868
897
  if (drift.length > 0) {
@@ -996,6 +1025,27 @@ export async function checkCompliance(repoPath, options = {}) {
996
1025
  const exactMatch = attestation.rule_digest === ref.rule_digest;
997
1026
  const grandfathered = !exactMatch && isAttestationGrandfathered(rule, attestation);
998
1027
  if (exactMatch || grandfathered) {
1028
+ if (attestation.outcome !== undefined &&
1029
+ attestation.outcome !== compliance.outcome &&
1030
+ !(attestation.file_scoped === true && isFileScopableRule(rule))) {
1031
+ const crossEngagementStatus = rule.advisory ? "advisory" : rule.enforcement.attestation.allowed ? "attestation-needed" : "deterministic-fail";
1032
+ results.push({
1033
+ ...checkRuleIdentity(rule.id),
1034
+ title: rule.title,
1035
+ severity: rule.severity,
1036
+ status: crossEngagementStatus,
1037
+ reason: rule.advisory
1038
+ ? "Advisory: informational only — does not affect compliance status."
1039
+ : `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.`,
1040
+ finding,
1041
+ recommendation: finding?.recommendation,
1042
+ rationale: rule.rationale,
1043
+ current_rule: ruleSummary(rule),
1044
+ stale_engagement_attestation: { outcome: attestation.outcome, attested_at: attestation.attested_at },
1045
+ ...(crossEngagementStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
1046
+ });
1047
+ continue;
1048
+ }
999
1049
  const sourceFingerprintStatus = await checkSourceFingerprints(repoRoot, inspection.effectiveRoot, attestation.source_fingerprints ?? []);
1000
1050
  const staleFingerprints = sourceFingerprintStatus.filter((item) => item.status !== "match");
1001
1051
  if (staleFingerprints.length > 0) {
@@ -1153,22 +1203,23 @@ export async function checkCompliance(repoPath, options = {}) {
1153
1203
  result.baselined = true;
1154
1204
  }
1155
1205
  }
1156
- const baselinedMissing = new Set(baselineFile.completeness_missing);
1157
- const baselinedSelected = new Set(baselineFile.selected_optional);
1206
+ const legacyDeliverableEntries = (baselineFile.completeness_missing?.length ?? 0) + (baselineFile.selected_optional?.length ?? 0);
1158
1207
  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
1208
  baselineReport = {
1163
1209
  present: true,
1164
1210
  applied: true,
1165
1211
  baselined_at: baselineFile.baselined_at,
1166
1212
  excluded: {
1167
1213
  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),
1214
+ completeness: 0,
1215
+ selected_optional: 0,
1171
1216
  },
1217
+ ...(legacyDeliverableEntries > 0
1218
+ ? {
1219
+ ignored_legacy_deliverable_entries: legacyDeliverableEntries,
1220
+ 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.",
1221
+ }
1222
+ : {}),
1172
1223
  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
1224
  };
1174
1225
  }
@@ -1668,6 +1719,29 @@ export async function statusCompliance(repoPath, options = {}) {
1668
1719
  reviewer_assignment: engagement.reviewer_assignment,
1669
1720
  },
1670
1721
  }),
1722
+ ...(await previousEngagementsSection(repoRoot, check.outcome)),
1723
+ };
1724
+ }
1725
+ async function previousEngagementsSection(repoRoot, activeOutcome) {
1726
+ const dir = engagementsDir(repoRoot);
1727
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
1728
+ const previous = [];
1729
+ for (const entry of entries) {
1730
+ if (!entry.isDirectory() || entry.name === activeOutcome) {
1731
+ continue;
1732
+ }
1733
+ const record = await readJsonIfExists(path.join(dir, entry.name, "record.json"));
1734
+ if (record) {
1735
+ previous.push(record);
1736
+ }
1737
+ }
1738
+ if (previous.length === 0) {
1739
+ return {};
1740
+ }
1741
+ previous.sort((a, b) => String(a.outcome).localeCompare(String(b.outcome)));
1742
+ return {
1743
+ previousEngagements: previous,
1744
+ 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
1745
  };
1672
1746
  }
1673
1747
  async function applicableRules(outcome, platforms) {
@@ -2435,6 +2509,7 @@ function buildAttestation(compliance, rule, signer, confidence, identity, ration
2435
2509
  evidence,
2436
2510
  source_fingerprints: sourceFingerprints,
2437
2511
  ...(fileScoped ? { file_scoped: true } : {}),
2512
+ outcome: compliance.outcome,
2438
2513
  rationale_for_attestation: rationale,
2439
2514
  attested_at: new Date().toISOString(),
2440
2515
  };
@@ -2791,6 +2866,8 @@ function sidecarReadme(compliance) {
2791
2866
  "",
2792
2867
  "Attestations include source fingerprints; `vise check` marks them stale if the cited files change.",
2793
2868
  "",
2869
+ "`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`.",
2870
+ "",
2794
2871
  ].join("\n");
2795
2872
  }
2796
2873
  function attestationsDir(repoRoot) {
@@ -2827,6 +2904,129 @@ function deriveGate(flags) {
2827
2904
  return { status: "runtime-proof-waived", exitCode: flags.allowProofWaiver ? 0 : 9 };
2828
2905
  return { status: "green", exitCode: 0 };
2829
2906
  }
2907
+ async function recordEngagementOnSwitch(repoRoot, previous, nextOutcome, finalCheck) {
2908
+ const previousIntake = await readJsonIfExists(path.join(sidecarDir(repoRoot), "intake.json"));
2909
+ const dir = path.join(engagementsDir(repoRoot), String(previous.outcome));
2910
+ await rm(dir, { recursive: true, force: true });
2911
+ await mkdir(dir, { recursive: true });
2912
+ const verdict = finalCheck
2913
+ ? { status: finalCheck.status, exitCode: finalCheck.exitCode }
2914
+ : { status: "unrecorded", exitCode: null, note: "The final check could not run at switch time; contract and intake are still recorded." };
2915
+ const record = {
2916
+ outcome: previous.outcome,
2917
+ ...(typeof previousIntake?.request === "string" ? { request: previousIntake.request } : {}),
2918
+ recorded_at: new Date().toISOString(),
2919
+ reason: `superseded by ${nextOutcome} init`,
2920
+ ruleset_digest: previous.ruleset_digest,
2921
+ verdict,
2922
+ };
2923
+ await writeJson(path.join(dir, "record.json"), record);
2924
+ if (finalCheck) {
2925
+ await writeJson(path.join(dir, "final-check.json"), finalCheck);
2926
+ }
2927
+ await writeJson(path.join(dir, "contract.json"), previous);
2928
+ if (previousIntake) {
2929
+ await writeJson(path.join(dir, "intake.json"), previousIntake);
2930
+ }
2931
+ return {
2932
+ from: previous.outcome,
2933
+ to: nextOutcome,
2934
+ recorded: path.join(complianceDirName, "engagements", String(previous.outcome)),
2935
+ last_verdict: verdict,
2936
+ 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).",
2937
+ };
2938
+ }
2939
+ function engagementsDir(repoRoot) {
2940
+ return path.join(sidecarDir(repoRoot), "engagements");
2941
+ }
2942
+ async function reVerdictOne(repoPath, repoRoot, outcome) {
2943
+ const dir = path.join(engagementsDir(repoRoot), outcome);
2944
+ const record = await readJsonIfExists(path.join(dir, "record.json"));
2945
+ const recorded = await readJsonIfExists(path.join(dir, "contract.json"));
2946
+ if (!record || !recorded) {
2947
+ const available = (await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []))
2948
+ .filter((entry) => entry.isDirectory())
2949
+ .map((entry) => entry.name);
2950
+ 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)."}`);
2951
+ }
2952
+ const inspection = await inspectProject(repoRoot, recorded.surface?.path === "." ? undefined : recorded.surface?.path);
2953
+ const platforms = Array.from(new Set([...inspection.platforms, ...(recorded.surface?.platforms ?? [])]));
2954
+ const rules = await applicableRules(recorded.outcome, platforms.length > 0 ? platforms : ["unknown"]);
2955
+ const synthetic = {
2956
+ ...recorded,
2957
+ ruleset_digest: digestJson(rules.map(ruleRef)),
2958
+ rules: rules.map(ruleRefForFile),
2959
+ generated_at: new Date().toISOString(),
2960
+ };
2961
+ const check = await checkCompliance(repoPath, { contract: synthetic });
2962
+ return { record, check, ruleset_upgraded: recorded.ruleset_digest !== synthetic.ruleset_digest };
2963
+ }
2964
+ export async function checkEngagementReVerdict(repoPath, outcome) {
2965
+ const repoRoot = path.resolve(repoPath);
2966
+ const { record, check, ruleset_upgraded } = await reVerdictOne(repoPath, repoRoot, outcome);
2967
+ const drifted = check.status !== "green";
2968
+ return {
2969
+ mode: "re-verdict",
2970
+ engagement: outcome,
2971
+ ...(typeof record.request === "string" ? { request: record.request } : {}),
2972
+ recorded_at: record.recorded_at,
2973
+ recorded_verdict: record.verdict,
2974
+ ruleset_upgraded,
2975
+ status: check.status,
2976
+ exitCode: drifted ? 11 : 0,
2977
+ summary: check.summary,
2978
+ ...(check.completeness ? { completeness: check.completeness } : {}),
2979
+ rules: check.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status)),
2980
+ 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.",
2981
+ };
2982
+ }
2983
+ export async function checkAllEngagements(repoPath, options = {}) {
2984
+ const repoRoot = path.resolve(repoPath);
2985
+ const active = await checkCompliance(repoPath, options);
2986
+ const activeNonGreenKeys = new Set(active.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status)).map((rule) => baselineKeyFor(rule)));
2987
+ const entries = await readdir(engagementsDir(repoRoot), { withFileTypes: true }).catch(() => []);
2988
+ const engagements = [];
2989
+ let anyDrift = false;
2990
+ for (const entry of entries) {
2991
+ if (!entry.isDirectory() || entry.name === active.outcome) {
2992
+ continue;
2993
+ }
2994
+ const { record, check, ruleset_upgraded } = await reVerdictOne(repoPath, repoRoot, entry.name);
2995
+ const nonGreen = check.rules.filter((rule) => BASELINE_NON_GREEN.has(rule.status));
2996
+ const gating = nonGreen.filter((rule) => !activeNonGreenKeys.has(baselineKeyFor(rule)));
2997
+ const shared = nonGreen.length - gating.length;
2998
+ const completenessMissing = check.completeness?.missing ?? [];
2999
+ const drifted = gating.length > 0 || completenessMissing.length > 0 || check.status === "contract-drift";
3000
+ anyDrift = anyDrift || drifted;
3001
+ engagements.push({
3002
+ outcome: entry.name,
3003
+ ...(typeof record.request === "string" ? { request: record.request } : {}),
3004
+ recorded_at: record.recorded_at,
3005
+ recorded_verdict: record.verdict,
3006
+ ruleset_upgraded,
3007
+ status: drifted ? check.status : "green",
3008
+ drift: drifted,
3009
+ ...(shared > 0 ? { shared_with_active: shared } : {}),
3010
+ ...(gating.length > 0 ? { gating } : {}),
3011
+ ...(completenessMissing.length > 0 ? { completeness_missing: completenessMissing.map((item) => item.id) } : {}),
3012
+ });
3013
+ }
3014
+ engagements.sort((a, b) => String(a.outcome).localeCompare(String(b.outcome)));
3015
+ const activeGreen = active.exitCode === 0;
3016
+ return {
3017
+ ...active,
3018
+ ...(activeGreen && anyDrift
3019
+ ? {
3020
+ status: "engagement-drift",
3021
+ exitCode: 11,
3022
+ active_status: active.status,
3023
+ active_exitCode: active.exitCode,
3024
+ }
3025
+ : {}),
3026
+ engagements,
3027
+ 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.",
3028
+ };
3029
+ }
2830
3030
  export async function recordBaseline(repoPath) {
2831
3031
  const repoRoot = path.resolve(repoPath);
2832
3032
  const compliance = await readCompliance(repoRoot);
@@ -2841,19 +3041,18 @@ export async function recordBaseline(repoPath) {
2841
3041
  const key = baselineKeyFor(result);
2842
3042
  findings[key] = (findings[key] ?? 0) + 1;
2843
3043
  }
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
- ];
3044
+ const deliverables_not_baselined = {
3045
+ completeness_missing: (check.completeness?.missing ?? []).length,
3046
+ selected_optional: (check.selectedOptionalCapabilities?.failed ?? []).length + (check.selectedOptionalCapabilities?.unknown ?? []).length,
3047
+ };
2849
3048
  const baseline = {
2850
3049
  baselined_at: new Date().toISOString(),
2851
3050
  vise_version: packageVersion,
2852
3051
  outcome: compliance.outcome,
2853
3052
  ruleset_digest: compliance.ruleset_digest,
2854
3053
  findings,
2855
- completeness_missing,
2856
- selected_optional,
3054
+ completeness_missing: [],
3055
+ selected_optional: [],
2857
3056
  };
2858
3057
  await writeJson(baselinePath(repoRoot), baseline);
2859
3058
  return {
@@ -2862,9 +3061,15 @@ export async function recordBaseline(repoPath) {
2862
3061
  outcome: baseline.outcome,
2863
3062
  recorded: {
2864
3063
  findings: Object.values(findings).reduce((a, b) => a + b, 0),
2865
- completeness_missing: completeness_missing.length,
2866
- selected_optional: selected_optional.length,
2867
3064
  },
3065
+ ...(deliverables_not_baselined.completeness_missing + deliverables_not_baselined.selected_optional > 0
3066
+ ? {
3067
+ deliverables_not_baselined: {
3068
+ ...deliverables_not_baselined,
3069
+ 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.",
3070
+ },
3071
+ }
3072
+ : {}),
2868
3073
  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
3074
  };
2870
3075
  }