@geraldmaron/construct 1.3.1 → 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.
- package/README.md +2 -1
- package/bin/construct +61 -15
- package/lib/a11y-audit.mjs +104 -0
- package/lib/artifact-completion-states.mjs +35 -0
- package/lib/artifact-completion.mjs +66 -0
- package/lib/artifact-gate-levels.mjs +69 -0
- package/lib/artifact-manifest.mjs +21 -0
- package/lib/artifact-release-gate.mjs +19 -1
- package/lib/artifact-workflow.mjs +16 -0
- package/lib/brand-contrast.mjs +60 -0
- package/lib/brand-tokens.mjs +14 -0
- package/lib/certification/artifact-gates.mjs +6 -1
- package/lib/cli-commands.mjs +5 -3
- package/lib/codex-config.mjs +7 -1
- package/lib/deck-export-pptx.mjs +223 -18
- package/lib/diagram-quality.mjs +161 -0
- package/lib/document-export.mjs +1 -1
- package/lib/env-config.mjs +8 -8
- package/lib/export-validate.mjs +106 -0
- package/lib/gates-audit.mjs +34 -0
- package/lib/health-check.mjs +23 -9
- package/lib/hooks/session-start.mjs +2 -1
- package/lib/init-unified.mjs +5 -3
- package/lib/mcp/server.mjs +52 -2
- package/lib/mcp/tool-recovery.mjs +56 -0
- package/lib/mcp/tools/web-search.mjs +94 -0
- package/lib/mcp-manager.mjs +33 -21
- package/lib/mcp-platform-config.mjs +44 -9
- package/lib/models/provider-poll.mjs +32 -6
- package/lib/orchestration/readiness.mjs +183 -0
- package/lib/output-quality.mjs +90 -0
- package/lib/pixel-regression.mjs +172 -0
- package/lib/providers/op-run.mjs +6 -5
- package/lib/providers/secret-audit-wiring.mjs +32 -0
- package/lib/providers/secret-resolver.mjs +90 -20
- package/lib/publish.mjs +64 -1
- package/lib/render-evidence.mjs +55 -0
- package/lib/render-pipeline.mjs +136 -0
- package/lib/service-manager.mjs +31 -10
- package/lib/templates/doc-presentation.mjs +24 -0
- package/lib/tracking-surfaces.mjs +36 -0
- package/lib/visual-review.mjs +70 -0
- package/package.json +1 -1
- package/scripts/sync-specialists.mjs +3 -3
- package/specialists/artifact-manifest.json +18 -0
- package/specialists/artifact-manifest.schema.json +46 -2
- package/templates/distribution/construct-brand.typ +1 -2
- package/templates/distribution/construct-deck.html +34 -34
- package/templates/distribution/construct-pdf.typ +1 -1
- package/templates/distribution/construct-web.html +8 -10
|
@@ -28,9 +28,18 @@ function resolveTemplateObject(input, resolvedValues, fallback) {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// A value is unsafe to write into a host MCP env block when it is an unresolved
|
|
32
|
+
// `__NAME__` template or a 1Password `op://` reference. The op:// form must never
|
|
33
|
+
// be written verbatim: a host that cannot run `op read` would pass the literal
|
|
34
|
+
// reference to the child process, and writing it persists secret topology on disk.
|
|
35
|
+
|
|
36
|
+
function isUnwritableEnvValue(value) {
|
|
37
|
+
return typeof value !== "string" || value.includes("__") || value.startsWith("op://");
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
function stripUnresolvedValues(input) {
|
|
32
41
|
return Object.fromEntries(
|
|
33
|
-
Object.entries(input).filter(([, value]) =>
|
|
42
|
+
Object.entries(input).filter(([, value]) => !isUnwritableEnvValue(value)),
|
|
34
43
|
);
|
|
35
44
|
}
|
|
36
45
|
|
|
@@ -40,14 +49,32 @@ function resolveArgs(args, resolvedValues) {
|
|
|
40
49
|
);
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
// Remote MCP header values carry secrets (e.g. `Authorization: Bearer __GITHUB_TOKEN__`).
|
|
53
|
+
// Emit a host-resolved env reference rather than the literal token value, so a live
|
|
54
|
+
// credential never lands in a config file on disk. Each host expands a different
|
|
55
|
+
// reference syntax; the secret stays in one place (config.env / the shell env) and the
|
|
56
|
+
// host resolves it at launch.
|
|
57
|
+
|
|
58
|
+
const SECRET_REF = {
|
|
59
|
+
claude: (name) => `\${${name}}`,
|
|
60
|
+
vscode: (name) => `\${env:${name}}`,
|
|
61
|
+
opencode: (name) => `{env:${name}}`,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Local/stdio MCP env is materialized as resolved values rather than host env-ref
|
|
65
|
+
// forms. The SECRET_REF syntaxes above are verified for the remote `headers` field
|
|
66
|
+
// only; whether Claude / VS Code / OpenCode expand a `${VAR}`-style reference inside
|
|
67
|
+
// a stdio `env` block is unconfirmed, so the value-to-reference flip is deferred. The
|
|
68
|
+
// secret is still kept off disk by the child inheriting resolved env from the parent
|
|
69
|
+
// (config.env -> loadConstructEnv -> process.env); `stripUnresolvedValues` guarantees
|
|
70
|
+
// no unresolved template or op:// reference is persisted here.
|
|
71
|
+
|
|
43
72
|
function buildLocalEnvironment(mcpDef, resolvedValues) {
|
|
44
73
|
return stripUnresolvedValues(resolveTemplateObject(mcpDef.env ?? {}, resolvedValues));
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
function buildRemoteHeaders(mcpDef,
|
|
48
|
-
return
|
|
49
|
-
resolveTemplateObject(mcpDef.headers ?? {}, resolvedValues, (name) => `{env:${name}}`),
|
|
50
|
-
);
|
|
76
|
+
function buildRemoteHeaders(mcpDef, secretRef) {
|
|
77
|
+
return resolveTemplateObject(mcpDef.headers ?? {}, {}, secretRef);
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
function withMemoryDefaults(id, values) {
|
|
@@ -56,11 +83,18 @@ function withMemoryDefaults(id, values) {
|
|
|
56
83
|
return { ...values, MEMORY_PORT: "8765" };
|
|
57
84
|
}
|
|
58
85
|
|
|
59
|
-
|
|
86
|
+
// auth defaults to "oauth": remote MCP servers (e.g. GitHub's hosted server) are
|
|
87
|
+
// configured by URL only, and the host runs the browser OAuth flow and stores the
|
|
88
|
+
// token in its own secure store — nothing secret touches the config file. "pat"
|
|
89
|
+
// is the opt-in fallback for non-OAuth contexts (headless/CI), emitting an env
|
|
90
|
+
// reference for the credential, never the literal value.
|
|
91
|
+
|
|
92
|
+
export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}, { host = "claude", auth = "oauth" } = {}) {
|
|
60
93
|
const values = withMemoryDefaults(id, { CX_TOOLKIT_DIR: ROOT_DIR, ...resolvedValues });
|
|
94
|
+
const secretRef = SECRET_REF[host] ?? SECRET_REF.claude;
|
|
61
95
|
|
|
62
96
|
if (mcpDef.type === "url") {
|
|
63
|
-
const headers = buildRemoteHeaders(mcpDef,
|
|
97
|
+
const headers = auth === "pat" ? buildRemoteHeaders(mcpDef, secretRef) : {};
|
|
64
98
|
const url =
|
|
65
99
|
id === "memory" && values.MEMORY_PORT
|
|
66
100
|
? `http://127.0.0.1:${values.MEMORY_PORT}/`
|
|
@@ -80,7 +114,7 @@ export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}) {
|
|
|
80
114
|
};
|
|
81
115
|
}
|
|
82
116
|
|
|
83
|
-
export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}) {
|
|
117
|
+
export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}, { auth = "oauth" } = {}) {
|
|
84
118
|
const runtimeValues = withMemoryDefaults(id, {
|
|
85
119
|
CX_TOOLKIT_DIR: ROOT_DIR,
|
|
86
120
|
...resolvedValues,
|
|
@@ -92,12 +126,13 @@ export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}) {
|
|
|
92
126
|
id === "memory" && runtimeValues.MEMORY_PORT
|
|
93
127
|
? `http://127.0.0.1:${runtimeValues.MEMORY_PORT}/`
|
|
94
128
|
: resolveTemplateString(mcpDef.url, runtimeValues);
|
|
129
|
+
const headers = auth === "pat" ? buildRemoteHeaders(mcpDef, SECRET_REF.opencode) : {};
|
|
95
130
|
return {
|
|
96
131
|
id: openCodeId,
|
|
97
132
|
entry: {
|
|
98
133
|
type: "remote",
|
|
99
134
|
url,
|
|
100
|
-
...(Object.keys(
|
|
135
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
101
136
|
},
|
|
102
137
|
};
|
|
103
138
|
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* fails) fall back to the curated tier options, flagged source:'curated' so the
|
|
9
9
|
* UI never implies a live result it could not verify. Every poller is
|
|
10
10
|
* best-effort: it resolves to [] on any error and never throws, and results are
|
|
11
|
-
* cached to ~/.cx/provider-catalog-cache.json so repeat opens are instant.
|
|
11
|
+
* cached to ~/.cx/provider-catalog-cache.json so repeat opens are instant. When a
|
|
12
|
+
* fresh catalog (within CACHE_TTL_MS) already covers every configured provider it
|
|
13
|
+
* is served directly without polling, so opening the picker on a warm cache
|
|
14
|
+
* resolves no secret and triggers no 1Password prompt.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import fs from 'node:fs';
|
|
@@ -306,6 +309,14 @@ async function enrichPricing(models, { env }) {
|
|
|
306
309
|
});
|
|
307
310
|
}
|
|
308
311
|
|
|
312
|
+
function sortProviderGroups(groups) {
|
|
313
|
+
return groups.sort((a, b) => {
|
|
314
|
+
const ai = PROVIDER_ORDER.indexOf(a.id);
|
|
315
|
+
const bi = PROVIDER_ORDER.indexOf(b.id);
|
|
316
|
+
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.label.localeCompare(b.label);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
309
320
|
/**
|
|
310
321
|
* Poll every configured provider in parallel and return one group per provider,
|
|
311
322
|
* in render order, each with its live model list. A provider that can't be
|
|
@@ -327,6 +338,25 @@ export async function pollConfiguredProviders({ env = process.env, cwd = process
|
|
|
327
338
|
}
|
|
328
339
|
}
|
|
329
340
|
|
|
341
|
+
// Cache-first: when a fresh catalog (within TTL) already covers every configured
|
|
342
|
+
// provider, serve it without resolving any secret. Opening the picker on a warm
|
|
343
|
+
// cache must not spawn `op read` or trigger a 1Password prompt.
|
|
344
|
+
const freshCached = readProviderCatalogCache({ homeDir, maxAgeMs: CACHE_TTL_MS }) || [];
|
|
345
|
+
const freshById = new Map(freshCached.map((g) => [g.id, g]));
|
|
346
|
+
if (groupIds.length && groupIds.every((gid) => freshById.get(gid)?.models?.length)) {
|
|
347
|
+
const fresh = groupIds.map((gid) => {
|
|
348
|
+
const group = groupForId.get(gid);
|
|
349
|
+
const cachedGroup = freshById.get(gid);
|
|
350
|
+
return {
|
|
351
|
+
id: gid,
|
|
352
|
+
label: group.label,
|
|
353
|
+
models: cachedGroup.models.map((m) => ({ ...m, source: 'cached' })),
|
|
354
|
+
live: false,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
return sortProviderGroups(fresh);
|
|
358
|
+
}
|
|
359
|
+
|
|
330
360
|
const cached = readProviderCatalogCache({ homeDir, maxAgeMs: Infinity }) || [];
|
|
331
361
|
const cachedById = new Map(cached.map((g) => [g.id, g]));
|
|
332
362
|
|
|
@@ -367,11 +397,7 @@ export async function pollConfiguredProviders({ env = process.env, cwd = process
|
|
|
367
397
|
groups.push({ id: gid, label: group.label, models: [unreachableHint(gid, group.label)], live: false });
|
|
368
398
|
}
|
|
369
399
|
|
|
370
|
-
groups
|
|
371
|
-
const ai = PROVIDER_ORDER.indexOf(a.id);
|
|
372
|
-
const bi = PROVIDER_ORDER.indexOf(b.id);
|
|
373
|
-
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.label.localeCompare(b.label);
|
|
374
|
-
});
|
|
400
|
+
sortProviderGroups(groups);
|
|
375
401
|
|
|
376
402
|
if (liveGroupsToCache.length) writeProviderCatalogCache(liveGroupsToCache, { homeDir });
|
|
377
403
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/readiness.mjs — observed orchestration attachment readiness.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from embedded-contract/execution.mjs: execution resolve
|
|
5
|
+
* reports what Construct can plan; readiness reports what a host/session or
|
|
6
|
+
* local probe actually exposed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { getInstalledVersion } from '../version.mjs';
|
|
13
|
+
import { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION } from '../embedded-contract/contract-version.mjs';
|
|
14
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
15
|
+
import { doctorRoot } from '../config/xdg.mjs';
|
|
16
|
+
|
|
17
|
+
export const ORCHESTRATION_READINESS_REASONS = Object.freeze([
|
|
18
|
+
'attached',
|
|
19
|
+
'host_not_attached',
|
|
20
|
+
'server_unreachable',
|
|
21
|
+
'auth_unavailable',
|
|
22
|
+
'profile_mismatch',
|
|
23
|
+
'capability_negotiation_failed',
|
|
24
|
+
'version_mismatch',
|
|
25
|
+
'tool_unlisted',
|
|
26
|
+
'unknown',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_ORCHESTRATION_TOOLS = Object.freeze([
|
|
30
|
+
'orchestration_policy',
|
|
31
|
+
'orchestration_run',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const NEXT_STEPS = Object.freeze({
|
|
35
|
+
attached: 'Proceed: call orchestration_policy, then orchestration_run when policy indicates an orchestrated or focused run.',
|
|
36
|
+
host_not_attached: 'Refresh the host adapter with `construct sync`, restart the host session, then rerun `construct orchestrate preflight --json` inside the target project.',
|
|
37
|
+
server_unreachable: 'Run `construct doctor`, verify the generated MCP command points at this checkout, then rerun `construct sync` if the server path is stale.',
|
|
38
|
+
auth_unavailable: 'Configure the required Construct/provider credential, restart the host so its MCP process inherits it, then rerun preflight.',
|
|
39
|
+
profile_mismatch: 'Check the active Construct scope/profile and host adapter selection, then rerun `construct sync` from the project root.',
|
|
40
|
+
capability_negotiation_failed: 'Restart the host session so MCP initialize/tools.list negotiation runs again; if it persists, attach the diagnostic bundle to GH #323.',
|
|
41
|
+
version_mismatch: 'Upgrade Construct and refresh adapters with `construct sync` so the host and server use compatible contract versions.',
|
|
42
|
+
tool_unlisted: 'Refresh adapters with `construct sync`; if using a reduced local tool surface, ensure orchestration_run is reachable through the `call` gateway enum.',
|
|
43
|
+
unknown: 'Run `construct doctor` and attach the diagnostic bundle to GH #323.',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function normalizeList(values) {
|
|
47
|
+
if (!values) return [];
|
|
48
|
+
const arr = Array.isArray(values) ? values : String(values).split(',');
|
|
49
|
+
return [...new Set(arr.map((v) => String(v || '').trim()).filter(Boolean))].sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compareSemver(a, b) {
|
|
53
|
+
const pa = String(a || '').split('.').map((n) => Number(n));
|
|
54
|
+
const pb = String(b || '').split('.').map((n) => Number(n));
|
|
55
|
+
for (let i = 0; i < 3; i += 1) {
|
|
56
|
+
const av = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
57
|
+
const bv = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
58
|
+
if (av > bv) return 1;
|
|
59
|
+
if (av < bv) return -1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasTool(tool, observed, reachable) {
|
|
65
|
+
return observed.includes(tool) || reachable.includes(tool);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function redactedEnvFacts(env) {
|
|
69
|
+
return {
|
|
70
|
+
hasRemoteOrchestrationUrl: Boolean(env.CONSTRUCT_ORCHESTRATION_URL),
|
|
71
|
+
hasRemoteOrchestrationToken: Boolean(env.CONSTRUCT_ORCHESTRATION_TOKEN || env.CONSTRUCT_DASHBOARD_TOKEN),
|
|
72
|
+
hasOpenRouterKey: Boolean(env.OPENROUTER_API_KEY),
|
|
73
|
+
hasAnthropicKey: Boolean(env.ANTHROPIC_API_KEY),
|
|
74
|
+
hasOpenAiKey: Boolean(env.OPENAI_API_KEY),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildOrchestrationReadiness(input = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
79
|
+
const requiredTools = normalizeList(input.requiredTools ?? input.required_tools ?? DEFAULT_ORCHESTRATION_TOOLS);
|
|
80
|
+
const observedTools = normalizeList(input.observedTools ?? input.observed_tools);
|
|
81
|
+
const reachableTools = normalizeList(input.reachableTools ?? input.reachable_tools);
|
|
82
|
+
const host = input.host ?? null;
|
|
83
|
+
const sessionId = input.sessionId ?? input.session_id ?? null;
|
|
84
|
+
const observationScope = input.observationScope ?? input.observation_scope ?? (observedTools.length || reachableTools.length ? 'host-session' : 'local-config');
|
|
85
|
+
const probeError = input.probeError ?? input.probe_error ?? null;
|
|
86
|
+
const clientContractVersion = input.clientContractVersion ?? input.client_contract_version ?? CONTRACT_VERSION;
|
|
87
|
+
const profileExpected = input.expectedProfile ?? input.expected_profile ?? null;
|
|
88
|
+
const profileActual = input.actualProfile ?? input.actual_profile ?? null;
|
|
89
|
+
const authRequired = Boolean(input.authRequired ?? input.auth_required);
|
|
90
|
+
|
|
91
|
+
const missingTools = requiredTools.filter((tool) => !hasTool(tool, observedTools, reachableTools));
|
|
92
|
+
const constructVersion = getInstalledVersion()?.version || 'unknown';
|
|
93
|
+
const diagnosticId = input.diagnosticId ?? input.diagnostic_id ?? `orch-${randomUUID()}`;
|
|
94
|
+
|
|
95
|
+
let reasonCode = 'attached';
|
|
96
|
+
let detail = 'Required orchestration tools are reachable.';
|
|
97
|
+
|
|
98
|
+
if (probeError) {
|
|
99
|
+
reasonCode = 'server_unreachable';
|
|
100
|
+
detail = String(probeError);
|
|
101
|
+
} else if (compareSemver(clientContractVersion, MIN_CLIENT_CONTRACT_VERSION) < 0) {
|
|
102
|
+
reasonCode = 'version_mismatch';
|
|
103
|
+
detail = `Client contract ${clientContractVersion} is older than minimum ${MIN_CLIENT_CONTRACT_VERSION}.`;
|
|
104
|
+
} else if (profileExpected && profileActual && profileExpected !== profileActual) {
|
|
105
|
+
reasonCode = 'profile_mismatch';
|
|
106
|
+
detail = `Expected profile ${profileExpected}, observed ${profileActual}.`;
|
|
107
|
+
} else if (authRequired && !(env.CONSTRUCT_ORCHESTRATION_TOKEN || env.CONSTRUCT_DASHBOARD_TOKEN)) {
|
|
108
|
+
reasonCode = 'auth_unavailable';
|
|
109
|
+
detail = 'A remote/team orchestration token is required but not configured.';
|
|
110
|
+
} else if (observationScope === 'host-session' && observedTools.length === 0 && reachableTools.length === 0) {
|
|
111
|
+
reasonCode = 'host_not_attached';
|
|
112
|
+
detail = 'No observed MCP tools were provided for the active host session.';
|
|
113
|
+
} else if (missingTools.length > 0) {
|
|
114
|
+
reasonCode = 'tool_unlisted';
|
|
115
|
+
detail = `Missing required tool(s): ${missingTools.join(', ')}.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const verdict = reasonCode === 'attached' ? 'pass' : 'fail';
|
|
119
|
+
const bundle = {
|
|
120
|
+
diagnosticId,
|
|
121
|
+
host,
|
|
122
|
+
sessionId,
|
|
123
|
+
cwd,
|
|
124
|
+
observationScope,
|
|
125
|
+
constructVersion,
|
|
126
|
+
contractVersion: CONTRACT_VERSION,
|
|
127
|
+
minClientContractVersion: MIN_CLIENT_CONTRACT_VERSION,
|
|
128
|
+
clientContractVersion,
|
|
129
|
+
deploymentMode: getDeploymentMode(env, { cwd }),
|
|
130
|
+
requiredTools,
|
|
131
|
+
observedTools,
|
|
132
|
+
reachableTools,
|
|
133
|
+
missingTools,
|
|
134
|
+
reasonCode,
|
|
135
|
+
detail,
|
|
136
|
+
env: redactedEnvFacts(env),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
verdict,
|
|
141
|
+
attached: verdict === 'pass',
|
|
142
|
+
reasonCode,
|
|
143
|
+
nextStep: NEXT_STEPS[reasonCode] || NEXT_STEPS.unknown,
|
|
144
|
+
host,
|
|
145
|
+
sessionId,
|
|
146
|
+
observationScope,
|
|
147
|
+
requiredTools,
|
|
148
|
+
observedTools,
|
|
149
|
+
reachableTools,
|
|
150
|
+
missingTools,
|
|
151
|
+
constructVersion,
|
|
152
|
+
contractVersion: CONTRACT_VERSION,
|
|
153
|
+
minClientContractVersion: MIN_CLIENT_CONTRACT_VERSION,
|
|
154
|
+
diagnosticBundle: bundle,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function summarizeOrchestrationReadiness(readiness) {
|
|
159
|
+
const state = readiness.attached ? 'Orchestration attached' : 'Orchestration unavailable';
|
|
160
|
+
const missing = readiness.missingTools?.length ? ` missing=${readiness.missingTools.join(',')}` : '';
|
|
161
|
+
const host = readiness.host ? ` host=${readiness.host}` : '';
|
|
162
|
+
return `${state} (${readiness.reasonCode}; scope=${readiness.observationScope}${host}${missing})`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function recordOrchestrationReadinessEvent(readiness, { homeDir, at = new Date().toISOString() } = {}) {
|
|
166
|
+
const event = {
|
|
167
|
+
ts: at,
|
|
168
|
+
type: 'orchestration.readiness',
|
|
169
|
+
host: readiness.host ?? null,
|
|
170
|
+
sessionId: readiness.sessionId ?? null,
|
|
171
|
+
observationScope: readiness.observationScope,
|
|
172
|
+
verdict: readiness.verdict,
|
|
173
|
+
attached: readiness.attached,
|
|
174
|
+
reasonCode: readiness.reasonCode,
|
|
175
|
+
requiredTools: readiness.requiredTools ?? [],
|
|
176
|
+
missingTools: readiness.missingTools ?? [],
|
|
177
|
+
diagnosticId: readiness.diagnosticBundle?.diagnosticId ?? null,
|
|
178
|
+
};
|
|
179
|
+
const file = join(doctorRoot(homeDir), 'orchestration-readiness.jsonl');
|
|
180
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
181
|
+
appendFileSync(file, `${JSON.stringify(event)}\n`);
|
|
182
|
+
return { path: file, event };
|
|
183
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/output-quality.mjs — Post-export output validation for the publish path.
|
|
3
|
+
*
|
|
4
|
+
* After an artifact exports, this runs the checks the source-level release gate cannot reach: the
|
|
5
|
+
* produced file is validated for real (PDF page count, exported-text roundtrip against the source
|
|
6
|
+
* headings, on-disk reference integrity), and the brand text palette is asserted against WCAG AA.
|
|
7
|
+
* At render-smoke or higher gate levels the export is additionally rendered to images and the
|
|
8
|
+
* screenshot evidence recorded. Every check degrades with a typed reason when its tool is absent
|
|
9
|
+
* rather than failing the publish; only a real, document-scoped validity failure (a zero-page PDF,
|
|
10
|
+
* dropped content, a missing local reference) marks the result not-ok.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { validatePdf, contentRoundtrip, referenceIntegrity } from './export-validate.mjs';
|
|
14
|
+
import { validateBrandContrast } from './brand-contrast.mjs';
|
|
15
|
+
import { captureRenderEvidence } from './render-evidence.mjs';
|
|
16
|
+
import { lintDocumentDiagrams } from './diagram-quality.mjs';
|
|
17
|
+
import { auditAccessibility } from './a11y-audit.mjs';
|
|
18
|
+
|
|
19
|
+
const RENDER_GATE_LEVELS = new Set(['render-smoke', 'full-certification', 'human-reviewed']);
|
|
20
|
+
|
|
21
|
+
// render-pipeline keys an export format to an image renderer; deck is HTML under the hood. A
|
|
22
|
+
// format with no renderer skips the screenshot capture rather than guessing one.
|
|
23
|
+
|
|
24
|
+
function renderFormatFor(format) {
|
|
25
|
+
if (format === 'pdf') return 'pdf';
|
|
26
|
+
if (format === 'html' || format === 'deck') return 'html';
|
|
27
|
+
if (format === 'pptx') return 'pptx';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runOutputQuality({
|
|
32
|
+
exportPath,
|
|
33
|
+
format,
|
|
34
|
+
sourceMarkdown = '',
|
|
35
|
+
baseDir = null,
|
|
36
|
+
gateLevel = 'standard',
|
|
37
|
+
diagramStrict = false,
|
|
38
|
+
rootDir = process.cwd(),
|
|
39
|
+
env = process.env,
|
|
40
|
+
} = {}) {
|
|
41
|
+
const checks = {};
|
|
42
|
+
|
|
43
|
+
if (format === 'pdf') checks.pdf = validatePdf(exportPath, env);
|
|
44
|
+
checks.roundtrip = contentRoundtrip({ exportPath, format, sourceMarkdown, env });
|
|
45
|
+
if (sourceMarkdown && baseDir) checks.references = referenceIntegrity(sourceMarkdown, baseDir);
|
|
46
|
+
|
|
47
|
+
// Diagram legibility is advisory by default; `cx_diagram_quality: strict` escalates it.
|
|
48
|
+
const diagrams = lintDocumentDiagrams(sourceMarkdown);
|
|
49
|
+
|
|
50
|
+
// Per-format accessibility, with an honest checked-vs-uncheckable coverage report.
|
|
51
|
+
const a11y = auditAccessibility({ format, exportPath, sourceMarkdown, env });
|
|
52
|
+
|
|
53
|
+
// Palette contrast is a brand-system invariant, not a property of this document, so a
|
|
54
|
+
// regression is surfaced as advisory rather than failing the user's publish.
|
|
55
|
+
|
|
56
|
+
const contrast = validateBrandContrast();
|
|
57
|
+
|
|
58
|
+
let render = null;
|
|
59
|
+
if (RENDER_GATE_LEVELS.has(gateLevel)) {
|
|
60
|
+
const renderFormat = renderFormatFor(format);
|
|
61
|
+
if (renderFormat) render = captureRenderEvidence({ format: renderFormat, inputPath: exportPath, rootDir, env });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A typed degradation (tool absent) never fails the publish; only a non-degraded,
|
|
65
|
+
// document-scoped check whose ok is explicitly false does.
|
|
66
|
+
|
|
67
|
+
const failures = Object.entries(checks)
|
|
68
|
+
.filter(([, result]) => result && result.ok === false && !result.degradation)
|
|
69
|
+
.map(([name, result]) => `${name}: ${result.message || 'failed'}`);
|
|
70
|
+
|
|
71
|
+
if (diagramStrict && !diagrams.ok) {
|
|
72
|
+
for (const w of diagrams.warnings) failures.push(`diagram ${w.diagram} (${w.kind}): ${w.code}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Accessibility is advisory at standard levels and escalates at full-certification and above;
|
|
76
|
+
// rendered-format limits stay in the uncheckable list, never a false pass.
|
|
77
|
+
const a11yStrict = gateLevel === 'full-certification' || gateLevel === 'human-reviewed';
|
|
78
|
+
if (a11yStrict) for (const f of a11y.failures || []) failures.push(`a11y ${f}`);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
ok: failures.length === 0,
|
|
82
|
+
gateLevel,
|
|
83
|
+
checks,
|
|
84
|
+
contrast: { ok: contrast.ok, failures: contrast.failures },
|
|
85
|
+
diagrams,
|
|
86
|
+
a11y,
|
|
87
|
+
render,
|
|
88
|
+
failures,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/pixel-regression.mjs — true pixel-level regression, gated to full-certification.
|
|
3
|
+
*
|
|
4
|
+
* Compares two PNGs by decoding them to raw pixels and counting pixels whose channels differ
|
|
5
|
+
* beyond a tolerance — not by comparing compressed file bytes, which are meaningless across
|
|
6
|
+
* encoders (PNG is DEFLATE-compressed, so two pixel-identical images can differ byte-for-byte).
|
|
7
|
+
* Decoding uses node:zlib only, honoring the zero-npm-core constraint (ADR-0001); 8-bit
|
|
8
|
+
* non-interlaced PNGs in the grayscale/RGB/GA/RGBA color types are supported and anything else
|
|
9
|
+
* returns a typed `unsupported-encoding` rather than a false pass.
|
|
10
|
+
*
|
|
11
|
+
* Fixture-regeneration policy: a golden is updated only through regenerateGolden (single writer),
|
|
12
|
+
* never edited in place. Pixel regression runs only at the full-certification gate level so its
|
|
13
|
+
* decode-and-compare cost (O(pixels), a few milliseconds per image) never burdens cheaper gates.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import zlib from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
export const PIXEL_REGRESSION_LEVEL = 'full-certification';
|
|
20
|
+
|
|
21
|
+
const CHANNELS_BY_COLOR_TYPE = Object.freeze({ 0: 1, 2: 3, 4: 2, 6: 4 });
|
|
22
|
+
|
|
23
|
+
function paeth(a, b, c) {
|
|
24
|
+
const p = a + b - c;
|
|
25
|
+
const pa = Math.abs(p - a);
|
|
26
|
+
const pb = Math.abs(p - b);
|
|
27
|
+
const pc = Math.abs(p - c);
|
|
28
|
+
if (pa <= pb && pa <= pc) return a;
|
|
29
|
+
if (pb <= pc) return b;
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Walk the PNG chunk stream for IHDR geometry and the concatenated IDAT payload, then inflate and
|
|
34
|
+
// reverse the per-scanline filter to recover raw pixels. CRCs are skipped; only the decode matters.
|
|
35
|
+
|
|
36
|
+
function decodePng(buffer) {
|
|
37
|
+
if (buffer.length < 8 || buffer[0] !== 0x89 || buffer[1] !== 0x50) return { error: 'invalid-png' };
|
|
38
|
+
let pos = 8;
|
|
39
|
+
let header = null;
|
|
40
|
+
const idat = [];
|
|
41
|
+
while (pos + 8 <= buffer.length) {
|
|
42
|
+
const length = buffer.readUInt32BE(pos);
|
|
43
|
+
const type = buffer.toString('ascii', pos + 4, pos + 8);
|
|
44
|
+
const dataStart = pos + 8;
|
|
45
|
+
if (type === 'IHDR') {
|
|
46
|
+
header = {
|
|
47
|
+
width: buffer.readUInt32BE(dataStart),
|
|
48
|
+
height: buffer.readUInt32BE(dataStart + 4),
|
|
49
|
+
bitDepth: buffer[dataStart + 8],
|
|
50
|
+
colorType: buffer[dataStart + 9],
|
|
51
|
+
interlace: buffer[dataStart + 12],
|
|
52
|
+
};
|
|
53
|
+
} else if (type === 'IDAT') {
|
|
54
|
+
idat.push(buffer.subarray(dataStart, dataStart + length));
|
|
55
|
+
} else if (type === 'IEND') {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
pos = dataStart + length + 4;
|
|
59
|
+
}
|
|
60
|
+
if (!header) return { error: 'invalid-png' };
|
|
61
|
+
const channels = CHANNELS_BY_COLOR_TYPE[header.colorType];
|
|
62
|
+
if (header.bitDepth !== 8 || header.interlace !== 0 || !channels) return { error: 'unsupported-encoding' };
|
|
63
|
+
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = zlib.inflateSync(Buffer.concat(idat));
|
|
67
|
+
} catch {
|
|
68
|
+
return { error: 'invalid-png' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { width, height } = header;
|
|
72
|
+
const stride = width * channels;
|
|
73
|
+
if (raw.length < height * (stride + 1)) return { error: 'invalid-png' };
|
|
74
|
+
|
|
75
|
+
const pixels = Buffer.alloc(height * stride);
|
|
76
|
+
let prior = Buffer.alloc(stride);
|
|
77
|
+
let rpos = 0;
|
|
78
|
+
for (let y = 0; y < height; y += 1) {
|
|
79
|
+
const filter = raw[rpos];
|
|
80
|
+
rpos += 1;
|
|
81
|
+
const recon = pixels.subarray(y * stride, y * stride + stride);
|
|
82
|
+
for (let x = 0; x < stride; x += 1) {
|
|
83
|
+
const left = x >= channels ? recon[x - channels] : 0;
|
|
84
|
+
const up = prior[x];
|
|
85
|
+
const upLeft = x >= channels ? prior[x - channels] : 0;
|
|
86
|
+
let value = raw[rpos + x];
|
|
87
|
+
if (filter === 1) value += left;
|
|
88
|
+
else if (filter === 2) value += up;
|
|
89
|
+
else if (filter === 3) value += (left + up) >> 1;
|
|
90
|
+
else if (filter === 4) value += paeth(left, up, upLeft);
|
|
91
|
+
recon[x] = value & 0xff;
|
|
92
|
+
}
|
|
93
|
+
rpos += stride;
|
|
94
|
+
prior = recon;
|
|
95
|
+
}
|
|
96
|
+
return { width, height, channels, pixels };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function comparePng(pathA, pathB, { tolerance = 0 } = {}) {
|
|
100
|
+
let bufferA;
|
|
101
|
+
let bufferB;
|
|
102
|
+
try {
|
|
103
|
+
bufferA = fs.readFileSync(pathA);
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false, reason: 'missing-image', identical: false, diffPixels: null, diffRatio: null };
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
bufferB = fs.readFileSync(pathB);
|
|
109
|
+
} catch {
|
|
110
|
+
return { ok: false, reason: 'missing-image', identical: false, diffPixels: null, diffRatio: null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (bufferA.equals(bufferB)) {
|
|
114
|
+
return { ok: true, reason: 'identical', identical: true, diffPixels: 0, diffRatio: 0 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const a = decodePng(bufferA);
|
|
118
|
+
const b = decodePng(bufferB);
|
|
119
|
+
if (a.error || b.error) {
|
|
120
|
+
return { ok: false, reason: a.error || b.error, identical: false, diffPixels: null, diffRatio: null };
|
|
121
|
+
}
|
|
122
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
123
|
+
return { ok: false, reason: 'dimension-mismatch', identical: false, diffPixels: null, diffRatio: null, dimsA: { width: a.width, height: a.height }, dimsB: { width: b.width, height: b.height } };
|
|
124
|
+
}
|
|
125
|
+
if (a.channels !== b.channels) {
|
|
126
|
+
return { ok: false, reason: 'channel-mismatch', identical: false, diffPixels: null, diffRatio: null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const total = a.width * a.height;
|
|
130
|
+
const threshold = Math.round(tolerance * 255);
|
|
131
|
+
let diffPixels = 0;
|
|
132
|
+
for (let p = 0; p < total; p += 1) {
|
|
133
|
+
let maxChannelDelta = 0;
|
|
134
|
+
for (let k = 0; k < a.channels; k += 1) {
|
|
135
|
+
const delta = Math.abs(a.pixels[p * a.channels + k] - b.pixels[p * a.channels + k]);
|
|
136
|
+
if (delta > maxChannelDelta) maxChannelDelta = delta;
|
|
137
|
+
}
|
|
138
|
+
if (maxChannelDelta > threshold) diffPixels += 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const diffRatio = total ? diffPixels / total : 0;
|
|
142
|
+
const ok = diffPixels === 0;
|
|
143
|
+
return {
|
|
144
|
+
ok,
|
|
145
|
+
reason: ok ? 'identical-pixels' : 'pixel-drift',
|
|
146
|
+
identical: false,
|
|
147
|
+
width: a.width,
|
|
148
|
+
height: a.height,
|
|
149
|
+
totalPixels: total,
|
|
150
|
+
diffPixels,
|
|
151
|
+
diffRatio: Math.round(diffRatio * 1e6) / 1e6,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function pixelRegressionGate({ level, currentPath, goldenPath, tolerance = 0 }) {
|
|
156
|
+
if (level !== PIXEL_REGRESSION_LEVEL) {
|
|
157
|
+
return { skipped: true, reason: 'pixel regression runs only at full-certification', ok: true };
|
|
158
|
+
}
|
|
159
|
+
return { ...comparePng(goldenPath, currentPath, { tolerance }), skipped: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The single sanctioned writer of a golden: copy the freshly-rendered image over it. No code path
|
|
163
|
+
// edits a golden in place, so regeneration stays deterministic and reviewable as one file swap.
|
|
164
|
+
|
|
165
|
+
export function regenerateGolden({ currentPath, goldenPath }) {
|
|
166
|
+
try {
|
|
167
|
+
fs.copyFileSync(currentPath, goldenPath);
|
|
168
|
+
return { ok: true, goldenPath };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { ok: false, reason: `regeneration failed: ${err.message}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
package/lib/providers/op-run.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* When CONSTRUCT_OP_ENV_FILE points to an `op run` env-file (KEY=op://… lines)
|
|
5
5
|
* and the `op` CLI is installed, long-lived Construct services are spawned through
|
|
6
|
-
* `op run --
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* resolved provider keys without a
|
|
6
|
+
* `op run --env-file <file> -- <cmd>`, so op:// references resolve once at startup
|
|
7
|
+
* into the process env. lib/service-manager.mjs calls wrapWithOpRun at each service
|
|
8
|
+
* spawn, so the memory server, OpenCode, the copilot bridge, the doctor daemon, and
|
|
9
|
+
* the oracle daemon all inherit resolved provider keys without a per-invocation
|
|
10
|
+
* shell wrapper. Masking stays on so resolved secrets are not echoed to service logs.
|
|
10
11
|
*
|
|
11
12
|
* Strictly opt-in: with the var unset, the file missing, or `op` absent, the
|
|
12
13
|
* command is returned unchanged and 1Password is never invoked. No other setup
|
|
@@ -51,7 +52,7 @@ export function wrapWithOpRun(command, args = [], { env = process.env, homeDir =
|
|
|
51
52
|
if (!file || !hasOp()) return { command, args, wrapped: false, envFile: null };
|
|
52
53
|
return {
|
|
53
54
|
command: 'op',
|
|
54
|
-
args: ['run',
|
|
55
|
+
args: ['run', `--env-file=${file}`, '--', command, ...args],
|
|
55
56
|
wrapped: true,
|
|
56
57
|
envFile: file,
|
|
57
58
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/secret-audit-wiring.mjs — connect secret resolution to the durable
|
|
3
|
+
* audit trail.
|
|
4
|
+
*
|
|
5
|
+
* enableSecretAuditTrail routes the value-free events from secret-resolver into the
|
|
6
|
+
* append-only audit log (lib/audit-trail.mjs). Only actual op:// resolutions are
|
|
7
|
+
* recorded — the prompt-bearing, security-significant events — not cache hits or
|
|
8
|
+
* high-frequency plain reads, so the resolve path is not serialized behind a file
|
|
9
|
+
* lock. The materialized secret value is never available to this layer and is never
|
|
10
|
+
* written. Wiring is per-process: the CLI entrypoint enables it; worker and daemon
|
|
11
|
+
* processes are a tracked follow-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendAuditRecord } from '../audit-trail.mjs';
|
|
15
|
+
import { setSecretAuditSink } from './secret-resolver.mjs';
|
|
16
|
+
|
|
17
|
+
export function enableSecretAuditTrail({ file } = {}) {
|
|
18
|
+
setSecretAuditSink((event) => {
|
|
19
|
+
if (event.event !== 'secret.op_read' || event.cacheHit) return;
|
|
20
|
+
appendAuditRecord(
|
|
21
|
+
{
|
|
22
|
+
ts: new Date().toISOString(),
|
|
23
|
+
tool: 'secret-resolver',
|
|
24
|
+
action: 'op_read',
|
|
25
|
+
ref: event.ref,
|
|
26
|
+
ok: event.ok,
|
|
27
|
+
code: event.code ?? null,
|
|
28
|
+
},
|
|
29
|
+
file ? { file } : {},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|