@growthub/cli 0.14.8 → 0.14.10

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 (16) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +21 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +1 -1
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +49 -1
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +38 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +27 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-readiness.js +212 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-contract-compliance.js +168 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-impact.js +198 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-impact.js +133 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-provenance-lineage.js +214 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-stale-surfaces.js +217 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-workflow-impact.js +170 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +13 -0
  15. package/dist/index.js +3024 -4191
  16. package/package.json +1 -1
@@ -39,6 +39,27 @@ mcpTools: []
39
39
 
40
40
  The **Workspace** is the top-level Growthub Local product object. This kit is the official starter that bootstraps every governed Workspace; everything else (worker kits, templates, workflows, hosted agents, source imports) is an input to a Workspace.
41
41
 
42
+ ## Your operating role (read first)
43
+
44
+ **If you are an agent reading this, you are the operating layer.** A human gave you an outcome; you operate this governed Workspace on their behalf. When the requester is a super admin or workspace admin, you can act as the super-admin orchestrator inside the governed boundary; when the requester is a regular user, you still do the orchestration work, but approval, review, rollback, and authority stay with the workspace/super-admin surfaces. You are not editing files and hoping tests catch mistakes — you are operating a live domain model where the platform rejects invalid reality before it lands and remembers everything that does. The chat you are in *is* the user's steering surface; the no-code Workspace is the control and audit plane.
45
+
46
+ **The human is usually non-technical, and asks for a business outcome — not workspace mechanics.** They will not mention the Data Model, metadata graph, routes, validators, the PATCH boundary, or the run console — those are the control plane and proof layer that exist so you can operate safely and the super admin can inspect everything. They ask in plain language: *research a customer avatar, run market/competitor research, generate this week's content batch, script and brief the next campaign, pull the latest campaign performance, update the client report, build an MRR-by-plan dashboard, run the revenue workflow, iterate on a feature, find what broke.* Your job is to translate that outcome into governed operations over **what already exists** in this Workspace (prebuilt workflows, objects, and procedures), execute it, and deliver the result — outputs saved, synced, inspectable, and governed. This **private repository is the customer's durable operating environment**: their kit, objects, workflows, procedures, rules, source records, docs, receipts, run history, and governance boundaries all live here; your session is just the operator entering that universe.
47
+
48
+ The completion bar is the user's real-world outcome, not a partial proposal or a hidden local artifact. Continue the governed loop until the workspace has objective proof: the relevant workflow ran or object changed, output rows/ledgers reflect the count and state, durable storage or source records hold the deliverables, receipts/run ids explain what happened, and any human-review state remains explicit. Do not hard-code one client's workflow as the pattern; swap the client, rows, registries, brand constraints, storage prefix, dashboard, and quality criteria while keeping the same governed causation loop.
49
+
50
+ Your loop, every time:
51
+
52
+ 1. **Inherit state** — read the `workspace:agent-outcomes` receipt stream (`GET /api/workspace/agent-outcomes`) and `.growthub-fork/project.md` to see what the last agent did; continue from `nextActions` / `rollbackRef`, don't redo work.
53
+ 2. **Check what exists** — a scheduled job, external API, data view, or multi-agent workflow is almost always already a governed object. Prefer operating an existing object over writing code.
54
+ 3. **Act only through governed routes** — `PATCH /api/workspace` (config) and `POST /api/workspace/sandbox-run` (execution); drafts via `workflow/publish`; proposals via `helper/apply`. There is no third path.
55
+ 4. **Let the validator correct you** — preflight, read the rejection reason, repair, retry. Rejections are navigation, not failure.
56
+ 5. **Persist the outcome** — count only connected, durable outputs; save accepted artifacts to the governed ledger/storage surface; keep generated binaries and secrets out of git.
57
+ 6. **Leave proof** — every governed action emits a secret-redacted receipt. The human does not need the mechanics; the super admin inspects all of it after the fact (Workspace Map, Run Console, outcome cockpit).
58
+
59
+ **Three roles:** the human states outcomes → you (the agent) operate → the workspace admin/super admin governs and audits. The mechanics of the boundary are in [`skills/governed-workspace-mutation/SKILL.md`](./skills/governed-workspace-mutation/SKILL.md) — read it before any mutation.
60
+
61
+ > **For the human operator:** you do not have to operate this Workspace yourself. Tell an agent what you want; it operates the Workspace through governed routes; you (or your admin) inspect every change with full proof and rollback. The no-code Builder is the governed substrate and the audit surface — not a tool you must personally drive.
62
+
42
63
  Every Growthub governed Workspace is materialised from this kit. The kit ships the `.growthub-fork/` contract (identity, policy, trace, optional authority), the `apps/workspace` no-code Workspace Builder, the validated `growthub.config.json` V1 contract, plus the six primitive layers Claude/Cursor/Codex agents operate against:
43
64
 
44
65
  1. **`SKILL.md`** — this file. Discovery entry + routing menu. Always loaded first; the full operator runbook (`skills.md`) is disclosed progressively when work begins.
@@ -18,7 +18,7 @@ It intentionally depends on adapter contracts:
18
18
  - `NANGO_ENVIRONMENT` (default `dev`)
19
19
  - `NANGO_MODE` (`cloud` | `self-hosted`, default `cloud`)
20
20
 
21
- The Growthub local-first operator shell remains at `../../studio`.
21
+ This `apps/workspace` app is the only bundled app surface; the legacy `studio/` Vite shell has been removed. It is the governed control plane and audit surface — non-technical users do not operate it directly; an agent operates the Workspace on their behalf through the governed routes (see the workspace `SKILL.md` operating-role contract), while super admins use this app for inspection, proof, and governance.
22
22
 
23
23
  Settings exposes two universal integration lanes:
24
24
 
@@ -35,6 +35,11 @@ import { readWorkspaceConfig, readWorkspaceSourceRecords } from "@/lib/workspace
35
35
  import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
36
36
  import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
37
37
  import { selectStaleMetadataGroups } from "@/lib/workspace-metadata-selectors";
38
+ import { deriveBlastRadius } from "@/lib/workspace-metadata-impact";
39
+ import { deriveStaleSurfaces } from "@/lib/workspace-stale-surfaces";
40
+ import { deriveWorkflowImpact } from "@/lib/workspace-workflow-impact";
41
+ import { deriveProvenanceLineage } from "@/lib/workspace-provenance-lineage";
42
+ import { deriveAppReadiness } from "@/lib/workspace-app-readiness";
38
43
 
39
44
  const ENVELOPE_KIND = "growthub-workspace-metadata-graph-v1";
40
45
  const ENVELOPE_VERSION = 1;
@@ -111,10 +116,17 @@ async function GET(request) {
111
116
  // Optional stale-group selector via query params.
112
117
  let staleGroups = [];
113
118
  let staleReasons = [];
119
+ // Parse all causal query params from one URL read.
120
+ let impactId = "";
121
+ let lineageId = "";
122
+ let lineageDirection = "both";
114
123
  try {
115
124
  const url = request && request.url ? new URL(request.url) : null;
116
125
  const staleKind = url ? (url.searchParams.get("staleKind") || "").trim() : "";
117
126
  const staleId = url ? (url.searchParams.get("staleId") || "").trim() : "";
127
+ impactId = url ? (url.searchParams.get("impactId") || "").trim() : "";
128
+ lineageId = url ? (url.searchParams.get("lineageId") || "").trim() : "";
129
+ lineageDirection = url ? (url.searchParams.get("lineageDirection") || "both").trim() : "both";
118
130
  if (staleKind && staleId) {
119
131
  const result = selectStaleMetadataGroups(metadataStore, { kind: staleKind, id: staleId });
120
132
  staleGroups = Array.isArray(result?.groups) ? result.groups : [];
@@ -124,6 +136,30 @@ async function GET(request) {
124
136
  warnings.push(`Failed to compute stale groups: ${error?.message || "unknown error"}`);
125
137
  }
126
138
 
139
+ // Causal derivations over the same read-only graph (Mutation → Law →
140
+ // Intelligence). All pure, all bounded, all secret-free. `staleSurfaces` is
141
+ // the unconditional freshness baseline (timestamps already in the graph);
142
+ // `impact` and `lineage` are computed on demand for one node.
143
+ let staleSurfaces = null;
144
+ let readiness = null;
145
+ let impact = null;
146
+ let lineage = null;
147
+ try {
148
+ staleSurfaces = deriveStaleSurfaces(graph);
149
+ readiness = deriveAppReadiness(graph);
150
+ if (impactId) {
151
+ impact = {
152
+ blastRadius: deriveBlastRadius(graph, impactId),
153
+ workflowImpact: deriveWorkflowImpact(graph, impactId)
154
+ };
155
+ }
156
+ if (lineageId) {
157
+ lineage = deriveProvenanceLineage(graph, lineageId, { direction: lineageDirection });
158
+ }
159
+ } catch (error) {
160
+ warnings.push(`Failed to compute causal derivations: ${error?.message || "unknown error"}`);
161
+ }
162
+
127
163
  return NextResponse.json({
128
164
  kind: ENVELOPE_KIND,
129
165
  version: ENVELOPE_VERSION,
@@ -163,6 +199,11 @@ async function GET(request) {
163
199
  groups: staleGroups,
164
200
  reasons: staleReasons
165
201
  },
202
+ // Causal intelligence layer — read-only derivations over `graph` above.
203
+ staleSurfaces,
204
+ readiness,
205
+ ...(impact ? { impact } : {}),
206
+ ...(lineage ? { lineage } : {}),
166
207
  warnings,
167
208
  selectors: {
168
209
  // Manifest of selectors the route honours. Only `selectStaleMetadataGroups`
@@ -170,7 +211,14 @@ async function GET(request) {
170
211
  // selectors are exposed as importable helpers for server-side consumers
171
212
  // and the read-only inspector; they are NOT toggled through query
172
213
  // params in V1.
173
- httpEnabled: ["selectStaleMetadataGroups"],
214
+ httpEnabled: [
215
+ "selectStaleMetadataGroups",
216
+ "deriveStaleSurfaces",
217
+ "deriveBlastRadius",
218
+ "deriveWorkflowImpact",
219
+ "deriveProvenanceLineage",
220
+ "deriveAppReadiness"
221
+ ],
174
222
  helperOnly: [
175
223
  "selectWidgetRequiredFields",
176
224
  "selectWorkflowNodeInputSchema",
@@ -35,6 +35,38 @@ import {
35
35
  } from "@/lib/workspace-patch-policy";
36
36
  import { evaluateAppScope, requireAppScope } from "@/lib/workspace-app-registry";
37
37
  import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
38
+ import { readWorkspaceSourceRecords } from "@/lib/workspace-config";
39
+ import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
40
+ import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
41
+ import { derivePatchImpact } from "@/lib/workspace-patch-impact";
42
+
43
+ /**
44
+ * Report the blast radius of a proposed patch BEFORE the write — the S1
45
+ * spine's intended preflight consumption. Builds the metadata graph from the
46
+ * MERGED config (what the workspace becomes if this patch lands), maps the
47
+ * patched dataModel objects / dashboards to their graph node ids, and runs the
48
+ * pure `deriveStaleSurfaces` seed path over them. Pure, additive, never
49
+ * throws — on any failure the preflight verdict is unaffected and `impact`
50
+ * is simply omitted.
51
+ */
52
+ async function computePatchImpact(currentConfig, mergedConfig, patch) {
53
+ try {
54
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) return null;
55
+ let sourceRecords = {};
56
+ try { sourceRecords = (await readWorkspaceSourceRecords()) || {}; } catch { sourceRecords = {}; }
57
+ // Build BOTH graphs so the impact deriver can report not just added/modified
58
+ // (on the merged graph) but also REMOVED objects/dashboards (whose downstream
59
+ // lived in the current graph). One shared, unit-tested deriver does the diff.
60
+ const buildGraph = (cfg) => buildWorkspaceMetadataGraph(buildWorkspaceMetadataStore({ workspaceConfig: cfg || {}, workspaceSourceRecords: sourceRecords }));
61
+ const currentGraph = buildGraph(currentConfig);
62
+ const mergedGraph = buildGraph(mergedConfig);
63
+ const impact = derivePatchImpact(currentGraph, mergedGraph, currentConfig || {}, mergedConfig || {});
64
+ if (!impact.total && !impact.removed.length) return null;
65
+ return impact;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
38
70
 
39
71
  async function POST(request) {
40
72
  let patch;
@@ -66,12 +98,14 @@ async function POST(request) {
66
98
  // PATCH about the merged result. Skipped when the body is not a plain
67
99
  // object (policy already reports that).
68
100
  let schema = { ok: true, errors: [] };
101
+ let mergedConfig = null;
69
102
  if (patch && typeof patch === "object" && !Array.isArray(patch)) {
70
103
  const sanitized = {};
71
104
  for (const key of WORKSPACE_PATCH_ALLOWED_FIELDS) {
72
105
  if (Object.prototype.hasOwnProperty.call(patch, key)) sanitized[key] = patch[key];
73
106
  }
74
107
  const merged = applyWorkspaceConfigPatch(currentConfig || {}, sanitized);
108
+ mergedConfig = merged;
75
109
  try {
76
110
  validateWorkspaceConfig({
77
111
  dashboards: merged.dashboards,
@@ -103,6 +137,9 @@ async function POST(request) {
103
137
  }
104
138
  }
105
139
 
140
+ // Blast radius of this patch BEFORE the write (additive; never blocks).
141
+ const impact = await computePatchImpact(currentConfig, mergedConfig, patch);
142
+
106
143
  const persistence = describePersistenceMode();
107
144
  const ok = policy.ok && schema.ok && (appScopeVerdict ? appScopeVerdict.allowed : true);
108
145
  const repairPlan = repairPlanForViolations(policy.violations);
@@ -140,6 +177,7 @@ async function POST(request) {
140
177
  schema,
141
178
  repairPlan,
142
179
  ...(appScopeVerdict ? { appScopeVerdict } : {}),
180
+ ...(impact ? { impact } : {}),
143
181
  ...(safeNextStep ? { safeNextStep } : {}),
144
182
  persistence: {
145
183
  mode: persistence.mode,
@@ -62,6 +62,27 @@ function buildAuthHeaders(record, secretValue) {
62
62
  return { [headerName]: prefix ? `${prefix} ${secretValue}` : secretValue };
63
63
  }
64
64
 
65
+ function executeFeatureSeedMock(url, { registryId, method, startedAt }) {
66
+ if (!String(url || "").startsWith("mock://growthub-feature-seed/")) return null;
67
+ return {
68
+ ok: true,
69
+ exitCode: 0,
70
+ durationMs: Date.now() - startedAt,
71
+ stdout: JSON.stringify({ ok: true, status: 200, data: [{ id: "rec-1", label: "Probe record", registryId }] }, null, 2),
72
+ stderr: "",
73
+ rawPayload: { ok: true, status: 200, data: [{ id: "rec-1", label: "Probe record", registryId }] },
74
+ httpStatus: 200,
75
+ adapterMeta: {
76
+ mode: "orchestration-graph",
77
+ registryId,
78
+ url,
79
+ httpStatus: 200,
80
+ method,
81
+ transport: "feature-seed-mock"
82
+ }
83
+ };
84
+ }
85
+
65
86
  function findRegistryRecord(workspaceConfig, registryId) {
66
87
  const id = String(registryId || "").trim();
67
88
  if (!id) return null;
@@ -203,6 +224,12 @@ async function executeApiRegistryCall(workspaceConfig, nodeConfig, inputPayload,
203
224
  }
204
225
  }
205
226
 
227
+ const mockResult = executeFeatureSeedMock(url, { registryId, method, startedAt });
228
+ if (mockResult) {
229
+ clearTimeout(timer);
230
+ return mockResult;
231
+ }
232
+
206
233
  try {
207
234
  const response = await fetch(url, {
208
235
  method,
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Growthub Workspace App-Readiness V1 — ship-readiness deriver.
3
+ *
4
+ * Composes the signals the workspace already records into ONE app-scoped
5
+ * eligibility verdict: `{ ready, blocking[], nextAction }`. Eligibility, not a
6
+ * flag — readiness is computed from the live state every call, never stored.
7
+ *
8
+ * Signal sources, all already present in the read-only metadata graph the
9
+ * Workspace Map builds:
10
+ * - integration.status — an unconnected integration blocks its dependents
11
+ * - sandbox.authStatus — an unauthenticated sandbox cannot run
12
+ * - pipelineHealth.status / latestOk — an untested or failing pipeline blocks
13
+ * - workflow.lifecycleStatus — a draft-only workflow is a soft (non-blocking)
14
+ * signal that proof is still pending
15
+ *
16
+ * Callers that hold richer signals (env-status, deploy check shape, swarm
17
+ * eligibility) may pass them via `options.extraBlockers` / `options.extraSignals`
18
+ * — they are merged in without changing the shape. Pure: no fetch, no fs, no
19
+ * writes, no secrets. Deterministic ordering so receipts/diffs stay clean.
20
+ */
21
+
22
+ const APP_READINESS_KIND = "growthub-workspace-app-readiness-v1";
23
+ const APP_READINESS_VERSION = 1;
24
+
25
+ // Severity rank → lower sorts first (most urgent blocker becomes nextAction).
26
+ const SEVERITY = { blocker: 0, warning: 1 };
27
+
28
+ function safeString(value) {
29
+ if (value == null) return "";
30
+ return typeof value === "string" ? value : String(value);
31
+ }
32
+
33
+ function isConnected(status) {
34
+ const s = safeString(status).toLowerCase();
35
+ return s === "connected" || s === "ok" || s === "active" || s === "ready";
36
+ }
37
+
38
+ // Three-state auth: "authed" | "unauthed" | "unknown". Empty is UNKNOWN, never
39
+ // silently "authed" — unknown auth must not pass as ready (review finding D).
40
+ function authState(status) {
41
+ const s = safeString(status).toLowerCase();
42
+ if (["authed", "authenticated", "ok", "connected", "ready"].includes(s)) return "authed";
43
+ if (!s) return "unknown";
44
+ return "unauthed";
45
+ }
46
+
47
+ // A local / no-auth sandbox legitimately has no credential — an intentional
48
+ // exception, not a silent default.
49
+ function isNoAuthSandbox(summary) {
50
+ const locality = safeString(summary.runLocality).toLowerCase();
51
+ const adapter = safeString(summary.adapter).toLowerCase();
52
+ const provider = safeString(summary.authProvider).toLowerCase();
53
+ return locality === "local" || adapter.includes("local") || provider === "none" || provider === "local";
54
+ }
55
+
56
+ /**
57
+ * @param {object} graph a `buildWorkspaceMetadataGraph` envelope
58
+ * @param {object} [options]
59
+ * @param {string} [options.appId] restrict to nodes scoped to this app (when
60
+ * node summaries carry `appId`/`appScope`); omit for workspace scope.
61
+ * @param {Array<{severity?:string,code:string,message:string,nextAction?:string}>} [options.extraBlockers]
62
+ * @param {object} [options.extraSignals] merged verbatim into `signals`.
63
+ * @returns {object} `{ kind, version, appId, ready, score, blocking[], warnings[], signals, nextAction, summary }`
64
+ */
65
+ function deriveAppReadiness(graph, options = {}) {
66
+ const appId = safeString(options.appId).trim() || null;
67
+
68
+ const empty = (warning) => ({
69
+ kind: APP_READINESS_KIND,
70
+ version: APP_READINESS_VERSION,
71
+ appId,
72
+ ready: false,
73
+ score: 0,
74
+ blocking: [],
75
+ warnings: warning ? [warning] : [],
76
+ signals: {},
77
+ nextAction: null,
78
+ summary: "No readiness computed."
79
+ });
80
+
81
+ if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
82
+ return empty("graph missing or malformed");
83
+ }
84
+
85
+ const inScope = (node) => {
86
+ if (!appId) return true;
87
+ const scope = node.summary && (node.summary.appId || node.summary.appScope);
88
+ return safeString(scope) === appId;
89
+ };
90
+
91
+ const nodes = graph.nodes.filter(inScope);
92
+ const issues = [];
93
+ const counts = { integrations: 0, sandboxes: 0, pipelines: 0, workflows: 0 };
94
+
95
+ for (const node of nodes) {
96
+ const s = node.summary || {};
97
+ if (node.type === "integration") {
98
+ counts.integrations += 1;
99
+ if (!isConnected(s.status)) {
100
+ issues.push({
101
+ severity: "blocker",
102
+ code: "integration_not_connected",
103
+ subject: node.label || node.id,
104
+ message: `Integration "${node.label || node.id}" is ${safeString(s.status) || "not connected"}.`,
105
+ nextAction: `Connect integration "${node.label || node.id}" (test-source / auth), then re-check readiness.`
106
+ });
107
+ }
108
+ } else if (node.type === "sandbox") {
109
+ counts.sandboxes += 1;
110
+ const state = authState(s.authStatus);
111
+ const subject = node.label || node.id;
112
+ if (state === "authed" || isNoAuthSandbox(s)) {
113
+ // authed, or a deliberate local/no-auth sandbox — ready.
114
+ } else if (state === "unknown") {
115
+ issues.push({
116
+ severity: "warning",
117
+ code: "sandbox_auth_unknown",
118
+ subject,
119
+ message: `Sandbox "${subject}" has no auth status — cannot confirm it can run, and it is not marked local/no-auth.`,
120
+ nextAction: `Authenticate sandbox "${subject}", or mark it local/no-auth (runLocality: local) if it needs no credential.`
121
+ });
122
+ } else {
123
+ issues.push({
124
+ severity: "blocker",
125
+ code: "sandbox_unauthenticated",
126
+ subject,
127
+ message: `Sandbox "${subject}" auth status is ${safeString(s.authStatus)}.`,
128
+ nextAction: `Authenticate sandbox "${subject}" before running.`
129
+ });
130
+ }
131
+ } else if (node.type === "pipelineHealth") {
132
+ counts.pipelines += 1;
133
+ const status = safeString(s.status).toLowerCase();
134
+ if (status === "untested" || s.latestOk === false) {
135
+ issues.push({
136
+ severity: status === "untested" ? "warning" : "blocker",
137
+ code: status === "untested" ? "pipeline_untested" : "pipeline_failing",
138
+ subject: node.label || node.id,
139
+ message: `Pipeline "${node.label || node.id}" is ${status || "failing"}.`,
140
+ nextAction: status === "untested"
141
+ ? `Run "${node.label || node.id}" once to prove it (POST /api/workspace/sandbox-run).`
142
+ : `Investigate the last failing run of "${node.label || node.id}".`
143
+ });
144
+ }
145
+ } else if (node.type === "workflow") {
146
+ counts.workflows += 1;
147
+ if (safeString(s.lifecycleStatus).toLowerCase() === "draft") {
148
+ issues.push({
149
+ severity: "warning",
150
+ code: "workflow_draft_only",
151
+ subject: node.label || node.id,
152
+ message: `Workflow "${node.label || node.id}" is draft-only — durable proof pending.`,
153
+ nextAction: `Prove "${node.label || node.id}" with a sandbox run, then publish.`
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ for (const extra of Array.isArray(options.extraBlockers) ? options.extraBlockers : []) {
160
+ if (!extra || !extra.code) continue;
161
+ issues.push({
162
+ severity: extra.severity === "warning" ? "warning" : "blocker",
163
+ code: safeString(extra.code),
164
+ subject: safeString(extra.subject) || safeString(extra.code),
165
+ message: safeString(extra.message) || safeString(extra.code),
166
+ nextAction: extra.nextAction ? safeString(extra.nextAction) : null
167
+ });
168
+ }
169
+
170
+ issues.sort((a, b) =>
171
+ (SEVERITY[a.severity] ?? 9) - (SEVERITY[b.severity] ?? 9) ||
172
+ a.code.localeCompare(b.code) ||
173
+ a.subject.localeCompare(b.subject)
174
+ );
175
+
176
+ const blocking = issues.filter((i) => i.severity === "blocker");
177
+ const warnings = issues.filter((i) => i.severity === "warning");
178
+ const ready = blocking.length === 0;
179
+
180
+ // Score: 100 when ready and clean; each blocker −25, each warning −5, floored at 0.
181
+ const score = Math.max(0, 100 - blocking.length * 25 - warnings.length * 5);
182
+ const nextAction = (blocking[0]?.nextAction)
183
+ || (warnings[0]?.nextAction)
184
+ || (ready ? "Ready — promote/deploy through the governed lane." : null);
185
+
186
+ return {
187
+ kind: APP_READINESS_KIND,
188
+ version: APP_READINESS_VERSION,
189
+ appId,
190
+ ready,
191
+ score,
192
+ blocking,
193
+ warnings,
194
+ signals: { ...counts, ...(options.extraSignals && typeof options.extraSignals === "object" ? options.extraSignals : {}) },
195
+ nextAction,
196
+ summary: summarizeReadiness(appId, ready, blocking, warnings, score)
197
+ };
198
+ }
199
+
200
+ function summarizeReadiness(appId, ready, blocking, warnings, score) {
201
+ const scope = appId ? `App "${appId}"` : "Workspace";
202
+ if (ready && !warnings.length) return `${scope} is ready to ship (score ${score}).`;
203
+ if (ready) return `${scope} is ready (score ${score}) with ${warnings.length} warning(s).`;
204
+ return `${scope} is blocked (score ${score}): ${blocking.length} blocker(s), ${warnings.length} warning(s).`;
205
+ }
206
+
207
+ export {
208
+ APP_READINESS_KIND,
209
+ APP_READINESS_VERSION,
210
+ deriveAppReadiness,
211
+ summarizeReadiness
212
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Growthub Workspace Contract-Compliance V1 — governed-mutation predicate.
3
+ *
4
+ * Given a PROPOSED mutation and a role/SKILL contract, derive the set of proofs,
5
+ * ledgers, and review states that must be satisfied for the mutation to be
6
+ * legal — and which of those are already satisfied by the supplied evidence.
7
+ *
8
+ * This is a PURE PREDICATE over law artifacts the caller already holds (the
9
+ * contract rules + the evidence gathered from receipts/review state). It reads
10
+ * NO state itself, writes nothing, and exposes no secrets. It does not widen
11
+ * any mutation boundary — it only reports what the existing governed lanes
12
+ * require, the same rules the `governed-workspace-mutation` SKILL encodes:
13
+ * - live workflow fields are never PATCHed directly — they need a draft, a
14
+ * proving sandbox-run, then publish;
15
+ * - dataModel / dashboard mutations need a preflight receipt;
16
+ * - anything outside the PATCH allowlist is rejected by construction.
17
+ *
18
+ * Default contract (when none supplied) mirrors the shipped boundary so the
19
+ * predicate is useful out of the box; callers pass a stricter contract per role.
20
+ */
21
+
22
+ const CONTRACT_COMPLIANCE_KIND = "growthub-workspace-contract-compliance-v1";
23
+ const CONTRACT_COMPLIANCE_VERSION = 1;
24
+
25
+ // The permanent PATCH allowlist — the floor every contract inherits.
26
+ const DEFAULT_ALLOWED_FIELDS = ["dashboards", "widgetTypes", "canvas", "dataModel"];
27
+
28
+ function safeString(value) {
29
+ if (value == null) return "";
30
+ return typeof value === "string" ? value : String(value);
31
+ }
32
+
33
+ function asArray(value) {
34
+ return Array.isArray(value) ? value : [];
35
+ }
36
+
37
+ function defaultContract() {
38
+ return {
39
+ role: "default",
40
+ allowedFields: DEFAULT_ALLOWED_FIELDS.slice(),
41
+ // Field groups that require a recorded preflight receipt before the write.
42
+ requiresReceiptFor: ["dataModel", "dashboards"],
43
+ // Field groups that require a human/super-admin review state.
44
+ requiresReviewFor: [],
45
+ // Live workflow changes must go draft → sandbox-run proof → publish.
46
+ liveWorkflowRequiresPublishProof: true
47
+ };
48
+ }
49
+
50
+ /**
51
+ * @param {object} mutation the proposed change.
52
+ * `{ changedFields: string[], touchesLiveWorkflow?: boolean, lane?: string }`
53
+ * @param {object} [contract] role/SKILL contract (see `defaultContract`).
54
+ * @param {object} [evidence] proof already gathered.
55
+ * `{ hasPreflightReceipt?: boolean, hasPublishProof?: boolean, reviewState?: string }`
56
+ * @returns {object} `{ kind, version, role, compliant, required[], satisfied[], missing[], violations[], nextAction, summary }`
57
+ */
58
+ function deriveContractCompliance(mutation, contract, evidence = {}) {
59
+ const rules = { ...defaultContract(), ...(contract && typeof contract === "object" ? contract : {}) };
60
+ const role = safeString(rules.role) || "default";
61
+
62
+ const empty = (warning) => ({
63
+ kind: CONTRACT_COMPLIANCE_KIND,
64
+ version: CONTRACT_COMPLIANCE_VERSION,
65
+ role,
66
+ compliant: false,
67
+ required: [],
68
+ satisfied: [],
69
+ missing: [],
70
+ violations: warning ? [{ code: "invalid_input", message: warning }] : [],
71
+ nextAction: null,
72
+ summary: warning || "No compliance computed."
73
+ });
74
+
75
+ if (!mutation || typeof mutation !== "object") return empty("mutation missing or malformed");
76
+
77
+ const changedFields = asArray(mutation.changedFields).map(safeString).filter(Boolean);
78
+ const allowed = new Set(asArray(rules.allowedFields).map(safeString));
79
+ const required = [];
80
+ const satisfied = [];
81
+ const missing = [];
82
+ const violations = [];
83
+
84
+ // 1. Allowlist — disallowed fields are a hard violation (rejected by the route).
85
+ for (const field of changedFields) {
86
+ if (!allowed.has(field)) {
87
+ violations.push({
88
+ code: "field_not_allowed",
89
+ field,
90
+ message: `Field "${field}" is outside the PATCH allowlist (${Array.from(allowed).join(", ")}).`
91
+ });
92
+ }
93
+ }
94
+
95
+ // 2. Preflight receipt requirement.
96
+ const receiptGroups = new Set(asArray(rules.requiresReceiptFor).map(safeString));
97
+ if (changedFields.some((f) => receiptGroups.has(f))) {
98
+ const req = { code: "preflight_receipt", message: "A recorded patch-preflight receipt is required." };
99
+ required.push(req);
100
+ (evidence.hasPreflightReceipt ? satisfied : missing).push(req);
101
+ }
102
+
103
+ // 3. Review state requirement.
104
+ const reviewGroups = new Set(asArray(rules.requiresReviewFor).map(safeString));
105
+ if (changedFields.some((f) => reviewGroups.has(f))) {
106
+ const req = { code: "review_state", message: "A human/super-admin review state is required (e.g. approved)." };
107
+ required.push(req);
108
+ const reviewOk = ["approved", "accepted", "merged"].includes(safeString(evidence.reviewState).toLowerCase());
109
+ (reviewOk ? satisfied : missing).push(req);
110
+ }
111
+
112
+ // 4. Live workflow proof chain.
113
+ if (mutation.touchesLiveWorkflow && rules.liveWorkflowRequiresPublishProof) {
114
+ const req = { code: "publish_proof", message: "Live workflow change requires draft → sandbox-run proof → publish." };
115
+ required.push(req);
116
+ (evidence.hasPublishProof ? satisfied : missing).push(req);
117
+ }
118
+
119
+ const compliant = violations.length === 0 && missing.length === 0;
120
+ const nextAction = violations.length
121
+ ? `Remove disallowed field(s): ${violations.filter((v) => v.code === "field_not_allowed").map((v) => v.field).join(", ") || "see violations"}.`
122
+ : (missing[0]
123
+ ? missingNextAction(missing[0])
124
+ : (compliant ? "Compliant — proceed through the governed PATCH/publish lane." : null));
125
+
126
+ return {
127
+ kind: CONTRACT_COMPLIANCE_KIND,
128
+ version: CONTRACT_COMPLIANCE_VERSION,
129
+ role,
130
+ compliant,
131
+ required,
132
+ satisfied,
133
+ missing,
134
+ violations,
135
+ nextAction,
136
+ summary: summarizeCompliance(role, compliant, required, missing, violations)
137
+ };
138
+ }
139
+
140
+ function missingNextAction(req) {
141
+ switch (req.code) {
142
+ case "preflight_receipt": return "Run POST /api/workspace/patch/preflight and record the receipt before PATCH.";
143
+ case "review_state": return "Obtain an approved review state before applying.";
144
+ case "publish_proof": return "Save a draft, prove it with sandbox-run, then POST /api/workspace/workflow/publish.";
145
+ default: return `Satisfy: ${req.message}`;
146
+ }
147
+ }
148
+
149
+ function summarizeCompliance(role, compliant, required, missing, violations) {
150
+ if (violations.length) {
151
+ return `Non-compliant (${role}): ${violations.length} hard violation(s) — ${violations[0].message}`;
152
+ }
153
+ if (compliant) {
154
+ return required.length
155
+ ? `Compliant (${role}): all ${required.length} requirement(s) satisfied.`
156
+ : `Compliant (${role}): no governed requirements triggered.`;
157
+ }
158
+ return `Non-compliant (${role}): ${missing.length} of ${required.length} requirement(s) unmet.`;
159
+ }
160
+
161
+ export {
162
+ CONTRACT_COMPLIANCE_KIND,
163
+ CONTRACT_COMPLIANCE_VERSION,
164
+ DEFAULT_ALLOWED_FIELDS,
165
+ defaultContract,
166
+ deriveContractCompliance,
167
+ summarizeCompliance
168
+ };