@geraldmaron/construct 1.3.2 → 1.4.1

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 (51) hide show
  1. package/README.md +2 -1
  2. package/bin/construct +61 -15
  3. package/lib/a11y-audit.mjs +104 -0
  4. package/lib/artifact-completion-states.mjs +35 -0
  5. package/lib/artifact-completion.mjs +66 -0
  6. package/lib/artifact-gate-levels.mjs +69 -0
  7. package/lib/artifact-manifest.mjs +21 -0
  8. package/lib/artifact-release-gate.mjs +19 -1
  9. package/lib/artifact-workflow.mjs +16 -0
  10. package/lib/brand-contrast.mjs +60 -0
  11. package/lib/brand-tokens.mjs +14 -0
  12. package/lib/cli-commands.mjs +5 -3
  13. package/lib/codex-config.mjs +7 -1
  14. package/lib/deck-export-pptx.mjs +223 -18
  15. package/lib/diagram-quality.mjs +161 -0
  16. package/lib/document-export.mjs +1 -1
  17. package/lib/env-config.mjs +8 -8
  18. package/lib/export-validate.mjs +106 -0
  19. package/lib/gates-audit.mjs +34 -0
  20. package/lib/health-check.mjs +23 -9
  21. package/lib/hooks/guard-bash.mjs +3 -1
  22. package/lib/hooks/session-start.mjs +2 -1
  23. package/lib/init-unified.mjs +5 -3
  24. package/lib/mcp/server.mjs +53 -3
  25. package/lib/mcp/tool-recovery.mjs +56 -0
  26. package/lib/mcp/tools/web-search.mjs +127 -0
  27. package/lib/mcp-manager.mjs +11 -4
  28. package/lib/mcp-platform-config.mjs +18 -1
  29. package/lib/models/provider-poll.mjs +32 -6
  30. package/lib/orchestration/readiness.mjs +183 -0
  31. package/lib/orchestration-policy.mjs +36 -0
  32. package/lib/output-quality.mjs +90 -0
  33. package/lib/pixel-regression.mjs +172 -0
  34. package/lib/providers/op-run.mjs +6 -5
  35. package/lib/providers/secret-audit-wiring.mjs +32 -0
  36. package/lib/providers/secret-resolver.mjs +90 -20
  37. package/lib/publish.mjs +64 -1
  38. package/lib/render-evidence.mjs +55 -0
  39. package/lib/render-pipeline.mjs +136 -0
  40. package/lib/service-manager.mjs +31 -10
  41. package/lib/templates/doc-presentation.mjs +24 -0
  42. package/lib/tracking-surfaces.mjs +36 -0
  43. package/lib/visual-review.mjs +70 -0
  44. package/package.json +1 -1
  45. package/scripts/sync-specialists.mjs +107 -11
  46. package/specialists/artifact-manifest.json +18 -0
  47. package/specialists/artifact-manifest.schema.json +46 -2
  48. package/templates/distribution/construct-brand.typ +1 -2
  49. package/templates/distribution/construct-deck.html +34 -34
  50. package/templates/distribution/construct-pdf.typ +1 -1
  51. package/templates/distribution/construct-web.html +8 -10
@@ -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.sort((a, b) => {
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
+ }
@@ -183,6 +183,38 @@ export function classifyResearchShape(request = '') {
183
183
  return null;
184
184
  }
185
185
 
186
+ // Live web access is distinct from research-shape: a bare "connect to the
187
+ // internet" or "fetch example.com" carries none of the comparative/landscape
188
+ // vocabulary, so without this it falls to the immediate track and the
189
+ // orchestrator — which holds no network tools — refuses instead of dispatching
190
+ // the web-capable cx-researcher. A literal URL, an explicit online/internet
191
+ // phrase, or a fetch/scrape verb paired with a web object is the signal.
192
+
193
+ const WEB_VERB = '(?:fetch|download|open|load|read|scrape|crawl|retrieve|pull|grab|access|visit|hit|reach)';
194
+ const WEB_OBJECT = '(?:url|link|web\\s?page|web\\s?site|site|page|online|internet|web|endpoint|api)';
195
+ const LIVE_WEB_ACCESS_PATTERNS = [
196
+ /https?:\/\/\S+/i,
197
+ /\bwww\.[a-z0-9-]+\.[a-z]{2,}/i,
198
+ /\b(?:connect|connecting|connection)\s+to\s+the\s+(?:internet|web|network)\b/i,
199
+ /\binternet\s+(?:access|connection|connectivity)\b/i,
200
+ /\b(?:go|get|getting|going)\s+online\b/i,
201
+ /\b(?:browse|browsing|surf)\b[\s\S]{0,20}\b(?:web|internet|site|page|url)\b/i,
202
+ /\b(?:curl|wget|ping)\b/i,
203
+ /\b(?:is|are)\b[\s\S]{0,40}\b(?:online|offline|reachable|unreachable|responding)\b/i,
204
+ new RegExp(`\\b${WEB_VERB}\\b[\\s\\S]{0,40}\\b${WEB_OBJECT}\\b`, 'i'),
205
+ new RegExp(`\\b${WEB_VERB}\\b[\\s\\S]{0,40}\\b[a-z0-9-]+\\.(?:com|org|net|io|dev|ai|gov|edu|co|app|info|xyz)\\b`, 'i'),
206
+ ];
207
+
208
+ /**
209
+ * Whether a request needs live external web/network access the orchestrator
210
+ * cannot perform itself (it is a read-only router with no network tools), so it
211
+ * must be routed to cx-researcher rather than answered immediately.
212
+ */
213
+ export function requiresLiveWebAccess(request = '') {
214
+ const text = String(request);
215
+ return LIVE_WEB_ACCESS_PATTERNS.some((pattern) => pattern.test(text));
216
+ }
217
+
186
218
  /**
187
219
  * Returns whether external research is required before scaffolding, with the
188
220
  * reason. Triggered by named entities not in the project glossary, by
@@ -193,6 +225,9 @@ export function requiresExternalResearch({ request = '', workCategory, riskFlags
193
225
  const entities = extractNamedEntities(request);
194
226
  const category = workCategory ?? classifyWorkCategory(request);
195
227
  const flags = riskFlags ?? detectRiskFlags(request);
228
+ if (requiresLiveWebAccess(request)) {
229
+ return { required: true, reason: 'web-access' };
230
+ }
196
231
  if (entities.length > 0) {
197
232
  return { required: true, reason: 'named-entities', entities };
198
233
  }
@@ -507,6 +542,7 @@ export function determineExecutionTrack({
507
542
  || isOperationsPlanningRequest(request)
508
543
  || isRdLeadRequest(request)
509
544
  || isExplorerRequest(request)
545
+ || requiresLiveWebAccess(request)
510
546
  ) {
511
547
  return EXECUTION_TRACKS.focused;
512
548
  }
@@ -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
+ }
@@ -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 --no-masking --env-file <file> -- <cmd>`, so op:// references resolve
7
- * once at startup into the process env. This mirrors the per-invocation model
8
- * other LLM CLIs use (e.g. `op run -- opencode`) and gives child processes
9
- * resolved provider keys without a shell wrapper.
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', '--no-masking', `--env-file=${file}`, '--', command, ...args],
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
+ }