@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.
Files changed (50) 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/certification/artifact-gates.mjs +6 -1
  13. package/lib/cli-commands.mjs +5 -3
  14. package/lib/codex-config.mjs +7 -1
  15. package/lib/deck-export-pptx.mjs +223 -18
  16. package/lib/diagram-quality.mjs +161 -0
  17. package/lib/document-export.mjs +1 -1
  18. package/lib/env-config.mjs +8 -8
  19. package/lib/export-validate.mjs +106 -0
  20. package/lib/gates-audit.mjs +34 -0
  21. package/lib/health-check.mjs +23 -9
  22. package/lib/hooks/session-start.mjs +2 -1
  23. package/lib/init-unified.mjs +5 -3
  24. package/lib/mcp/server.mjs +52 -2
  25. package/lib/mcp/tool-recovery.mjs +56 -0
  26. package/lib/mcp/tools/web-search.mjs +94 -0
  27. package/lib/mcp-manager.mjs +33 -21
  28. package/lib/mcp-platform-config.mjs +44 -9
  29. package/lib/models/provider-poll.mjs +32 -6
  30. package/lib/orchestration/readiness.mjs +183 -0
  31. package/lib/output-quality.mjs +90 -0
  32. package/lib/pixel-regression.mjs +172 -0
  33. package/lib/providers/op-run.mjs +6 -5
  34. package/lib/providers/secret-audit-wiring.mjs +32 -0
  35. package/lib/providers/secret-resolver.mjs +90 -20
  36. package/lib/publish.mjs +64 -1
  37. package/lib/render-evidence.mjs +55 -0
  38. package/lib/render-pipeline.mjs +136 -0
  39. package/lib/service-manager.mjs +31 -10
  40. package/lib/templates/doc-presentation.mjs +24 -0
  41. package/lib/tracking-surfaces.mjs +36 -0
  42. package/lib/visual-review.mjs +70 -0
  43. package/package.json +1 -1
  44. package/scripts/sync-specialists.mjs +3 -3
  45. package/specialists/artifact-manifest.json +18 -0
  46. package/specialists/artifact-manifest.schema.json +46 -2
  47. package/templates/distribution/construct-brand.typ +1 -2
  48. package/templates/distribution/construct-deck.html +34 -34
  49. package/templates/distribution/construct-pdf.typ +1 -1
  50. package/templates/distribution/construct-web.html +8 -10
@@ -2,13 +2,22 @@
2
2
  * lib/providers/secret-resolver.mjs — single credential resolver for the LLM path.
3
3
  *
4
4
  * Resolves a canonical environment variable (for example ANTHROPIC_API_KEY) by
5
- * walking the same sources the rest of Construct trusts, in priority order:
6
- * process env, the XDG config dir config.env, ~/.env, the project .env, then shell rc
7
- * files (.zshrc/.bashrc/.bash_profile/.profile). When the value carries a
5
+ * walking Construct's credential sources in priority order: a direct value on the
6
+ * passed env, then the project .env, the XDG config dir config.env, ~/.env, the
7
+ * alternate provider stores (Construct creds store, OpenCode provider config), the
8
+ * CONSTRUCT_OP_ENV_FILE catalog, and finally shell rc files
9
+ * (.zshrc/.bashrc/.bash_profile/.profile). The file tier matches loadConstructEnv:
10
+ * the project .env wins over the machine-wide config.env. When the value carries a
8
11
  * 1Password reference — a bare `op://vault/item/field` or the shell form
9
12
  * `$(op read 'op://...')` — it is resolved through the `op` CLI and cached for
10
13
  * the process lifetime so a single launch prompts at most once per reference.
11
14
  *
15
+ * One divergence from loadConstructEnv is intentional and tracked separately: that
16
+ * merge ranks the config files above process.env to defeat stale shell exports,
17
+ * while this resolver honors a directly-passed env value first so a hermetic caller
18
+ * can inject its own credentials. Unifying the process.env tier touches the live
19
+ * LLM path and is deferred to a follow-up.
20
+ *
12
21
  * resolveSecret performs the `op read` (and may surface a structured
13
22
  * SecretResolutionError); hasSecret only checks presence and never invokes the
14
23
  * CLI, so detection stays fast and free of biometric prompts. The plaintext
@@ -33,6 +42,30 @@ const OP_READ_TIMEOUT_MS = 20000;
33
42
 
34
43
  const opCache = new Map();
35
44
 
45
+ let auditSink = null;
46
+
47
+ // A structured, value-free record of resolution events: which variable, which
48
+ // source tier, whether it carried an op:// reference, cache-hit, and the outcome —
49
+ // never the materialized secret. The sink defaults to none (hermetic); a caller
50
+ // wires it to the observation store. Sink failures never disrupt resolution.
51
+
52
+ export function setSecretAuditSink(sink) {
53
+ auditSink = typeof sink === 'function' ? sink : null;
54
+ }
55
+
56
+ export function __resetSecretAuditSink() {
57
+ auditSink = null;
58
+ }
59
+
60
+ function emitAudit(event) {
61
+ if (!auditSink) return;
62
+ try {
63
+ auditSink(event);
64
+ } catch {
65
+ /* audit is best-effort */
66
+ }
67
+ }
68
+
36
69
  export class SecretResolutionError extends Error {
37
70
  constructor(message, code) {
38
71
  super(message);
@@ -74,10 +107,19 @@ function defaultOpRead(opRef) {
74
107
  }
75
108
 
76
109
  export function resolveOpRef(opRef, { opRead = defaultOpRead } = {}) {
77
- if (opCache.has(opRef)) return opCache.get(opRef);
78
- const secret = opRead(opRef);
79
- opCache.set(opRef, secret);
80
- return secret;
110
+ if (opCache.has(opRef)) {
111
+ emitAudit({ event: 'secret.op_read', ref: opRef, cacheHit: true, ok: true });
112
+ return opCache.get(opRef);
113
+ }
114
+ try {
115
+ const secret = opRead(opRef);
116
+ opCache.set(opRef, secret);
117
+ emitAudit({ event: 'secret.op_read', ref: opRef, cacheHit: false, ok: true });
118
+ return secret;
119
+ } catch (err) {
120
+ emitAudit({ event: 'secret.op_read', ref: opRef, cacheHit: false, ok: false, code: err?.code });
121
+ throw err;
122
+ }
81
123
  }
82
124
 
83
125
  function materialize(rawValue, opts) {
@@ -140,26 +182,54 @@ function readFromOpEnvCatalog(varName, { env, home }) {
140
182
 
141
183
  function rawCandidate(varName, { env, cwd, home }) {
142
184
  const direct = env?.[varName];
143
- if (typeof direct === 'string' && direct.length > 0) return direct;
144
- const files = [path.join(configDir(home), 'config.env'), path.join(home, '.env'), path.join(cwd, '.env')];
145
- for (const file of files) {
185
+ if (typeof direct === 'string' && direct.length > 0) return { raw: direct, source: 'env' };
186
+
187
+ // File tier matches loadConstructEnv: the project .env (closest to the work)
188
+ // wins over the machine-wide XDG config.env, which wins over a generic ~/.env.
189
+ const files = [
190
+ { file: path.join(cwd, '.env'), source: 'project-env' },
191
+ { file: path.join(configDir(home), 'config.env'), source: 'config-env' },
192
+ { file: path.join(home, '.env'), source: 'home-env' },
193
+ ];
194
+ for (const { file, source } of files) {
146
195
  const value = readDotenvVar(file, varName);
147
- if (value) return value;
196
+ if (value) return { raw: value, source };
148
197
  }
149
198
  const alt = discoverAlternateRawForVar(varName, { home });
150
- if (alt) return alt;
199
+ if (alt) return { raw: alt, source: 'alt-store' };
151
200
  const fromOpCatalog = readFromOpEnvCatalog(varName, { env, home });
152
- if (fromOpCatalog) return fromOpCatalog;
153
- return readShellRcVar(varName, home);
201
+ if (fromOpCatalog) return { raw: fromOpCatalog, source: 'op-catalog' };
202
+ const fromRc = readShellRcVar(varName, home);
203
+ if (fromRc) return { raw: fromRc, source: 'shell-rc' };
204
+ return null;
205
+ }
206
+
207
+ function resolveAndAudit(varName, raw, source, opts) {
208
+ const isOpRef = Boolean(extractOpRef(raw));
209
+ try {
210
+ const value = materialize(raw, opts);
211
+ emitAudit({ event: 'secret.resolve', varName, source, isOpRef, ok: true });
212
+ return value;
213
+ } catch (err) {
214
+ emitAudit({ event: 'secret.resolve', varName, source, isOpRef, ok: false, code: err?.code });
215
+ throw err;
216
+ }
154
217
  }
155
218
 
156
219
  export function resolveSecret(varName, { env = process.env, cwd = process.cwd(), allowAmbient = true, opRead } = {}) {
157
220
  const direct = env?.[varName];
158
- if (typeof direct === 'string' && direct.length > 0) return materialize(direct, { opRead });
159
- if (!allowAmbient) return null;
221
+ if (typeof direct === 'string' && direct.length > 0) return resolveAndAudit(varName, direct, 'env', { opRead });
222
+ if (!allowAmbient) {
223
+ emitAudit({ event: 'secret.resolve', varName, source: null, isOpRef: false, ok: false });
224
+ return null;
225
+ }
160
226
  const home = os.homedir();
161
- const raw = rawCandidate(varName, { env, cwd, home });
162
- return raw ? materialize(raw, { opRead }) : null;
227
+ const found = rawCandidate(varName, { env, cwd, home });
228
+ if (!found) {
229
+ emitAudit({ event: 'secret.resolve', varName, source: null, isOpRef: false, ok: false });
230
+ return null;
231
+ }
232
+ return resolveAndAudit(varName, found.raw, found.source, { opRead });
163
233
  }
164
234
 
165
235
  export function resolveFirstSecret(varNames, opts = {}) {
@@ -178,8 +248,8 @@ export function hasSecret(varName, { env = process.env, cwd = process.cwd(), all
178
248
  if (typeof direct === 'string' && direct.length > 0) return true;
179
249
  if (!allowAmbient) return false;
180
250
  const home = os.homedir();
181
- const raw = rawCandidate(varName, { env, cwd, home });
182
- return typeof raw === 'string' && raw.length > 0;
251
+ const found = rawCandidate(varName, { env, cwd, home });
252
+ return Boolean(found && typeof found.raw === 'string' && found.raw.length > 0);
183
253
  }
184
254
 
185
255
  export function hasAnySecret(varNames, opts = {}) {
package/lib/publish.mjs CHANGED
@@ -15,6 +15,9 @@ import { loadDemoRecording } from './demo-recording.mjs';
15
15
  import { recordPlaywrightDemo } from './playwright-demo.mjs';
16
16
  import { validateArtifactRelease } from './artifact-release-gate.mjs';
17
17
  import { inferArtifactTypeFromPath } from './artifact-type-from-path.mjs';
18
+ import { runOutputQuality } from './output-quality.mjs';
19
+ import { resolveArtifactWorkflowContract } from './artifact-manifest.mjs';
20
+ import { resolveGateLevel } from './artifact-gate-levels.mjs';
18
21
 
19
22
  export { parsePublishFrontmatter };
20
23
 
@@ -50,6 +53,7 @@ export function runPublish({
50
53
  strict = true,
51
54
  sourceOnly = false,
52
55
  gate = true,
56
+ preview = false,
53
57
  artifactType = null,
54
58
  cwd = process.cwd(),
55
59
  repoRoot,
@@ -79,7 +83,7 @@ export function runPublish({
79
83
  env,
80
84
  });
81
85
 
82
- const ledger = { export: null, demos: [], recordings: [], detection, gate: null };
86
+ const ledger = { export: null, demos: [], recordings: [], detection, gate: null, validation: null };
83
87
  const resolvedType = artifactType || inferArtifactTypeFromPath(resolvedIn, { rootDir: cwd });
84
88
 
85
89
  if (gate && !sourceOnly && resolvedType) {
@@ -123,6 +127,36 @@ export function runPublish({
123
127
  if (!ledger.export.ok && strict && !sourceOnly) {
124
128
  return { ok: false, strict: true, ledger, message: ledger.export.message };
125
129
  }
130
+
131
+ // Post-export output validation: validate the produced file, its content roundtrip, and
132
+ // its references; render-smoke and higher levels also capture screenshot evidence. The
133
+ // gate level comes from the artifact's quality contract (defaults to standard).
134
+
135
+ if (ledger.export.ok) {
136
+ let gateLevel = 'standard';
137
+ try {
138
+ const contract = resolveArtifactWorkflowContract(resolvedType, { rootDir: repoRoot || cwd, cwd });
139
+ gateLevel = resolveGateLevel(contract?.qualityContract);
140
+ } catch { /* default level */ }
141
+
142
+ // --preview forces a render so the user can inspect output before claiming done, even when
143
+ // the artifact's own gate level would not otherwise capture screenshots.
144
+ if (preview && gateLevel !== 'full-certification' && gateLevel !== 'human-reviewed') {
145
+ gateLevel = 'render-smoke';
146
+ }
147
+ const sourceMarkdown = fs.readFileSync(resolvedIn, 'utf8');
148
+ const diagramStrict = /^---\n[\s\S]*?\bcx_diagram_quality:\s*strict\b[\s\S]*?\n---/.test(sourceMarkdown);
149
+ ledger.validation = runOutputQuality({
150
+ exportPath: ledger.export.outputPath,
151
+ format,
152
+ sourceMarkdown,
153
+ baseDir: path.dirname(resolvedIn),
154
+ gateLevel,
155
+ diagramStrict,
156
+ rootDir: cwd,
157
+ env,
158
+ });
159
+ }
126
160
  } else {
127
161
  ledger.export = { ok: true, skipped: 'source-only' };
128
162
  }
@@ -183,6 +217,7 @@ Options:
183
217
  --demo=<name> Terminal VHS tape name (repeatable)
184
218
  --recording=<name> Playwright recording manifest (repeatable)
185
219
  --no-figures Skip pandoc-ext/diagram filter
220
+ --preview Render the export to images and report what was verified
186
221
  --no-gate Skip artifact release gate (maintainer escape hatch)
187
222
  --source-only Write sources only; skip rendering
188
223
  --no-strict Do not exit 2 when toolchain or gate fails
@@ -211,6 +246,7 @@ export async function runPublishCli(argv = [], { cwd = process.cwd(), repoRoot }
211
246
  gate: true,
212
247
  sourceOnly: false,
213
248
  detect: false,
249
+ preview: false,
214
250
  help: false,
215
251
  input: null,
216
252
  };
@@ -223,6 +259,7 @@ export async function runPublishCli(argv = [], { cwd = process.cwd(), repoRoot }
223
259
  if (arg === '--source-only') { options.sourceOnly = true; continue; }
224
260
  if (arg === '--no-figures') { options.figures = false; continue; }
225
261
  if (arg === '--no-gate') { options.gate = false; continue; }
262
+ if (arg === '--preview') { options.preview = true; continue; }
226
263
  if (arg === '--detect') { options.detect = true; continue; }
227
264
  if (arg === '--to') { options.format = argv[++i]; continue; }
228
265
  if (arg.startsWith('--to=')) { options.format = arg.slice(5); continue; }
@@ -267,12 +304,38 @@ export async function runPublishCli(argv = [], { cwd = process.cwd(), repoRoot }
267
304
  gate: options.gate,
268
305
  artifactType: options.type,
269
306
  sourceOnly: options.sourceOnly,
307
+ preview: options.preview,
270
308
  cwd,
271
309
  repoRoot,
272
310
  });
273
311
 
274
312
  if (result.ok) {
275
313
  console.log(result.message);
314
+ const validation = result.ledger?.validation;
315
+ if (validation) {
316
+ const pdf = validation.checks?.pdf;
317
+ if (pdf?.pageCount) console.log(` validity: ${pdf.pageCount} page(s)`);
318
+ const roundtrip = validation.checks?.roundtrip;
319
+ if (roundtrip && !roundtrip.degradation) console.log(` content: ${roundtrip.message}`);
320
+ if (validation.render?.evidence && !validation.render.result?.degradation) {
321
+ console.log(` render: ${validation.render.result.images.length} screenshot(s) captured`);
322
+ if (options.preview) for (const img of validation.render.result.images) console.log(` ${path.relative(cwd, img)}`);
323
+ } else if (options.preview && validation.render?.result?.degradation) {
324
+ console.log(` render: skipped (${validation.render.result.degradation})`);
325
+ }
326
+ for (const w of validation.diagrams?.warnings || []) {
327
+ console.log(` diagram ${w.diagram} (${w.kind}): ${w.code} — ${w.detail}`);
328
+ }
329
+ const a11y = validation.a11y;
330
+ if (a11y && !a11y.unsupported) {
331
+ const checked = a11y.coverage.checked.map((c) => `${c.id}:${c.status}`).join(' ');
332
+ console.log(` a11y [${a11y.format}]: ${checked}`);
333
+ if (a11y.coverage.uncheckable.length) {
334
+ console.log(` a11y uncheckable: ${a11y.coverage.uncheckable.map((u) => u.id).join(', ')}`);
335
+ }
336
+ }
337
+ for (const f of validation.failures || []) console.log(` ⚠ ${f}`);
338
+ }
276
339
  if (result.ledger?.demos?.length) {
277
340
  for (const d of result.ledger.demos) {
278
341
  if (d.tapePath) console.log(` tape: ${d.tapePath}`);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * lib/render-evidence.mjs — Bridge from the render pipeline to the completion ledger.
3
+ *
4
+ * captureRenderEvidence renders an artifact to images under a stable .cx/render path and returns a
5
+ * screenshot-captured evidence object. A successful render carries the image paths and a content
6
+ * digest as proof and advances the ladder; a degraded render carries the typed degradation reason
7
+ * so it is recorded for the trail but never lifts the state (highestState skips degraded entries).
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { renderToImages } from './render-pipeline.mjs';
13
+ import { makeEvidence } from './artifact-completion.mjs';
14
+
15
+ export function renderEvidenceDir(rootDir, artifactName) {
16
+ return path.join(rootDir ?? process.cwd(), '.cx', 'render', artifactName);
17
+ }
18
+
19
+ // A digest over the rendered bytes lets a reader confirm the stored images are the ones the
20
+ // evidence claims; null when nothing was produced.
21
+
22
+ function digestImages(images) {
23
+ if (!images.length) return null;
24
+ const hash = crypto.createHash('sha256');
25
+ for (const image of images) hash.update(fs.readFileSync(image));
26
+ return `sha256:${hash.digest('hex')}`;
27
+ }
28
+
29
+ export function captureRenderEvidence({ format, inputPath, outDir, rootDir, actor = 'construct-render', env } = {}) {
30
+ const artifactName = path.basename(inputPath ?? `artifact.${format}`);
31
+ const dir = outDir ?? renderEvidenceDir(rootDir, artifactName);
32
+ const result = renderToImages({ format, inputPath, outDir: dir, env });
33
+
34
+ if (!result.ok) {
35
+ return {
36
+ result,
37
+ evidence: makeEvidence('screenshot-captured', {
38
+ actor,
39
+ artifact: inputPath ?? null,
40
+ degradation: result.degradation,
41
+ proof: { format, message: result.message },
42
+ }),
43
+ };
44
+ }
45
+
46
+ return {
47
+ result,
48
+ evidence: makeEvidence('screenshot-captured', {
49
+ actor,
50
+ artifact: inputPath,
51
+ digest: digestImages(result.images),
52
+ proof: { format, images: result.images, count: result.images.length, dir },
53
+ }),
54
+ };
55
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * lib/render-pipeline.mjs — Render exported artifacts to inspectable images.
3
+ *
4
+ * A declarative per-format renderer registry (PDF/HTML/PPTX → page or slide PNGs, Mermaid/D2 →
5
+ * SVG/PNG) plus availability detection that reuses the document-export binary probe. When a
6
+ * renderer is absent the result carries a typed degradation reason from the shared enum instead of
7
+ * a silent skip, so a caller can downgrade the completion state honestly. The actual render runs
8
+ * only when its tools resolve on PATH; detection and degradation need no tools and are
9
+ * deterministic.
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { whichBin } from './document-export.mjs';
16
+
17
+ const CHROME_CANDIDATES = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable'];
18
+ const SOFFICE_CANDIDATES = ['soffice', 'libreoffice'];
19
+
20
+ // Each format declares the binaries its render needs (all must resolve), the image kind it emits,
21
+ // and candidate names for tools that ship under different names per platform.
22
+
23
+ export const RENDERERS = Object.freeze({
24
+ pdf: { tools: ['pdftoppm'], kind: 'png' },
25
+ html: { tools: ['chromium'], candidates: { chromium: CHROME_CANDIDATES }, kind: 'png' },
26
+ pptx: { tools: ['soffice', 'pdftoppm'], candidates: { soffice: SOFFICE_CANDIDATES }, kind: 'png' },
27
+ mermaid: { tools: ['mmdc'], kind: 'png' },
28
+ d2: { tools: ['d2'], kind: 'svg' },
29
+ });
30
+
31
+ export const RENDERABLE_FORMATS = Object.freeze(Object.keys(RENDERERS));
32
+
33
+ function resolveTool(name, candidates, env) {
34
+ const names = candidates?.[name] ?? [name];
35
+ for (const candidate of names) {
36
+ const found = whichBin(candidate, env);
37
+ if (found) return { name, resolved: candidate, path: found };
38
+ }
39
+ return { name, resolved: null, path: null };
40
+ }
41
+
42
+ export function detectRenderer(format, env = process.env) {
43
+ const spec = RENDERERS[format];
44
+ if (!spec) {
45
+ return { format, available: false, unsupported: true, tools: [], missing: [], message: `No renderer for format: ${format}` };
46
+ }
47
+ const tools = spec.tools.map((tool) => resolveTool(tool, spec.candidates, env));
48
+ const missing = tools.filter((tool) => !tool.path).map((tool) => tool.name);
49
+ return {
50
+ format,
51
+ available: missing.length === 0,
52
+ unsupported: false,
53
+ tools,
54
+ missing,
55
+ message: missing.length === 0
56
+ ? `Ready: render ${format} to ${spec.kind}`
57
+ : `Missing renderer tool(s): ${missing.join(', ')}`,
58
+ };
59
+ }
60
+
61
+ function toolPath(detection, name) {
62
+ return detection.tools.find((tool) => tool.name === name)?.path ?? name;
63
+ }
64
+
65
+ function producedFiles(outDir, prefix, ext) {
66
+ return fs.readdirSync(outDir)
67
+ .filter((file) => file.startsWith(prefix) && file.endsWith(ext))
68
+ .sort()
69
+ .map((file) => path.join(outDir, file));
70
+ }
71
+
72
+ // Each render function runs its real command and returns the produced image paths, or throws so
73
+ // the caller can record a typed degradation. Commands are gated behind detection, so they only run
74
+ // when their tools resolved.
75
+
76
+ const RENDER_FNS = {
77
+ pdf(inputPath, outDir, detection) {
78
+ const result = spawnSync(toolPath(detection, 'pdftoppm'), ['-png', inputPath, path.join(outDir, 'page')], { encoding: 'utf8' });
79
+ if (result.status !== 0) throw new Error(result.stderr || 'pdftoppm failed');
80
+ return producedFiles(outDir, 'page', '.png');
81
+ },
82
+ html(inputPath, outDir, detection) {
83
+ const out = path.join(outDir, 'page.png');
84
+ const result = spawnSync(toolPath(detection, 'chromium'), [
85
+ '--headless', '--no-sandbox', '--hide-scrollbars',
86
+ `--screenshot=${out}`, '--window-size=1280,1696',
87
+ pathToFileURL(path.resolve(inputPath)).href,
88
+ ], { encoding: 'utf8' });
89
+ if (result.status !== 0 || !fs.existsSync(out)) throw new Error(result.stderr || 'chromium screenshot failed');
90
+ return [out];
91
+ },
92
+ mermaid(inputPath, outDir, detection) {
93
+ const out = path.join(outDir, 'diagram.png');
94
+ const result = spawnSync(toolPath(detection, 'mmdc'), ['-i', inputPath, '-o', out], { encoding: 'utf8' });
95
+ if (result.status !== 0 || !fs.existsSync(out)) throw new Error(result.stderr || 'mmdc failed');
96
+ return [out];
97
+ },
98
+ d2(inputPath, outDir, detection) {
99
+ const out = path.join(outDir, 'diagram.svg');
100
+ const result = spawnSync(toolPath(detection, 'd2'), [inputPath, out], { encoding: 'utf8' });
101
+ if (result.status !== 0 || !fs.existsSync(out)) throw new Error(result.stderr || 'd2 failed');
102
+ return [out];
103
+ },
104
+ pptx(inputPath, outDir, detection) {
105
+ const conv = spawnSync(toolPath(detection, 'soffice'), ['--headless', '--convert-to', 'pdf', '--outdir', outDir, inputPath], { encoding: 'utf8' });
106
+ if (conv.status !== 0) throw new Error(conv.stderr || 'soffice convert failed');
107
+ const pdf = path.join(outDir, path.basename(inputPath).replace(/\.pptx$/i, '.pdf'));
108
+ if (!fs.existsSync(pdf)) throw new Error('soffice produced no pdf');
109
+ const result = spawnSync(toolPath(detection, 'pdftoppm'), ['-png', pdf, path.join(outDir, 'slide')], { encoding: 'utf8' });
110
+ if (result.status !== 0) throw new Error(result.stderr || 'pdftoppm failed');
111
+ return producedFiles(outDir, 'slide', '.png');
112
+ },
113
+ };
114
+
115
+ function degraded(format, degradation, message, detection) {
116
+ return { ok: false, format, images: [], degradation, message, detection };
117
+ }
118
+
119
+ export function renderToImages({ format, inputPath, outDir, env = process.env } = {}) {
120
+ const detection = detectRenderer(format, env);
121
+ if (detection.unsupported) return degraded(format, 'unsupported-format', detection.message, detection);
122
+ if (!detection.available) return degraded(format, 'missing-dependency', detection.message, detection);
123
+ if (!inputPath || !fs.existsSync(inputPath)) {
124
+ return degraded(format, 'skipped-by-policy', `input not found: ${inputPath}`, detection);
125
+ }
126
+
127
+ fs.mkdirSync(outDir, { recursive: true });
128
+ try {
129
+ const images = RENDER_FNS[format](inputPath, outDir, detection);
130
+ if (!images.length) return degraded(format, 'unavailable-renderer', `renderer produced no images for ${format}`, detection);
131
+ return { ok: true, format, images, degradation: null, message: `Rendered ${images.length} image(s) for ${format}`, detection };
132
+ } catch (err) {
133
+ const reason = format === 'html' ? 'headless-limitation' : 'unavailable-renderer';
134
+ return degraded(format, reason, err.message, detection);
135
+ }
136
+ }
@@ -163,6 +163,20 @@ async function isCopilotBridgeRunning(port) {
163
163
  return probeRuntimePort(port);
164
164
  }
165
165
 
166
+ // Opt-in 1Password resolution (ADR-0049 Design A): each long-lived service launches
167
+ // under `op run --env-file -- <cmd>` so op:// references resolve once at this stable
168
+ // parent and every child inherits resolved provider keys. wrapWithOpRun returns the
169
+ // command unchanged when CONSTRUCT_OP_ENV_FILE is unset, the file is missing, or `op`
170
+ // is absent, so the un-opted path is byte-for-byte the prior behavior. The env that
171
+ // carries CONSTRUCT_OP_ENV_FILE is options.env when a site sets one (doctor, oracle)
172
+ // and process.env otherwise (cm, opencode, copilot bridge).
173
+
174
+ function wrapServiceSpawn(command, args, homeDir, options = {}) {
175
+ const env = options.env || process.env;
176
+ const wrapped = wrapWithOpRun(command, args, { env, homeDir });
177
+ return { command: wrapped.command, args: wrapped.args };
178
+ }
179
+
166
180
  function spawnDetached(command, args, homeDir, logFile, options = {}) {
167
181
  const logPath = path.join(runtimeStateDir(homeDir), logFile);
168
182
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
@@ -211,7 +225,9 @@ export function startDoctor({ rootDir, homeDir = os.homedir() } = {}) {
211
225
  if (existing) return { started: false, reused: true, pid: existing.pid };
212
226
  const indexPath = path.join(INSTALL_ROOT, 'lib', 'doctor', 'index.mjs');
213
227
  if (!fs.existsSync(indexPath)) return { started: false, reason: 'missing-binary' };
214
- const { logPath } = spawnDetached('node', [indexPath], homeDir, 'doctor.log', { cwd: rootDir, env: { ...process.env } });
228
+ const doctorEnv = { ...process.env };
229
+ const launch = wrapServiceSpawn('node', [indexPath], homeDir, { env: doctorEnv });
230
+ const { logPath } = spawnDetached(launch.command, launch.args, homeDir, 'doctor.log', { cwd: rootDir, env: doctorEnv });
215
231
  return { started: true, logPath };
216
232
  }
217
233
 
@@ -223,13 +239,15 @@ export function startOracle({ rootDir, homeDir = os.homedir(), projectDir = root
223
239
  if (!fs.existsSync(entry)) return { started: false, reason: 'missing-binary' };
224
240
  const live = readHeartbeat(heartbeatPath(homeDir));
225
241
  if (live) return { started: false, reused: true, pid: live.pid };
226
- const { logPath } = spawnDetached('node', [entry], homeDir, 'oracle-daemon.log', {
242
+ const oracleEnv = {
243
+ ...process.env,
244
+ CONSTRUCT_ORACLE_ROOT: rootDir,
245
+ CONSTRUCT_ORACLE_PROJECT: projectDir,
246
+ };
247
+ const launch = wrapServiceSpawn('node', [entry], homeDir, { env: oracleEnv });
248
+ const { logPath } = spawnDetached(launch.command, launch.args, homeDir, 'oracle-daemon.log', {
227
249
  cwd: projectDir,
228
- env: {
229
- ...process.env,
230
- CONSTRUCT_ORACLE_ROOT: rootDir,
231
- CONSTRUCT_ORACLE_PROJECT: projectDir,
232
- },
250
+ env: oracleEnv,
233
251
  });
234
252
  return { started: true, logPath };
235
253
  }
@@ -372,7 +390,8 @@ export async function startServices({
372
390
  if (await memoryProbeFn(ports.memory)) {
373
391
  results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'reused' });
374
392
  } else {
375
- spawnDetachedFn('cm', ['serve', '--port', String(ports.memory)], homeDir, 'cm.log');
393
+ const cm = wrapServiceSpawn('cm', ['serve', '--port', String(ports.memory)], homeDir, { env: liveEnv });
394
+ spawnDetachedFn(cm.command, cm.args, homeDir, 'cm.log');
376
395
  results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'started' });
377
396
  }
378
397
  } else if (wants('memory')) {
@@ -384,7 +403,8 @@ export async function startServices({
384
403
  if (await openCodeProbeFn(ports.bridge)) {
385
404
  results.push({ name: 'OpenCode', url: `http://127.0.0.1:${ports.bridge}`, status: 'reused' });
386
405
  } else {
387
- spawnDetachedFn('opencode', ['serve', '--port', String(ports.bridge)], homeDir, 'opencode.log');
406
+ const opencode = wrapServiceSpawn('opencode', ['serve', '--port', String(ports.bridge)], homeDir, { env: liveEnv });
407
+ spawnDetachedFn(opencode.command, opencode.args, homeDir, 'opencode.log');
388
408
  results.push({ name: 'OpenCode', url: `http://127.0.0.1:${ports.bridge}`, status: 'started' });
389
409
  }
390
410
  }
@@ -399,7 +419,8 @@ export async function startServices({
399
419
  } else {
400
420
  const proxyPath = path.join(INSTALL_ROOT, 'lib', 'bridges', 'copilot-proxy.mjs');
401
421
  if (fs.existsSync(proxyPath)) {
402
- spawnDetachedFn('node', [proxyPath, '--port', String(ports.copilotBridge)], homeDir, 'copilot-bridge.log');
422
+ const copilot = wrapServiceSpawn('node', [proxyPath, '--port', String(ports.copilotBridge)], homeDir, { env: liveEnv });
423
+ spawnDetachedFn(copilot.command, copilot.args, homeDir, 'copilot-bridge.log');
403
424
  results.push({ name: 'Copilot Bridge', url: `http://127.0.0.1:${ports.copilotBridge}`, status: 'started' });
404
425
  } else {
405
426
  results.push({ name: 'Copilot Bridge', status: 'unavailable', note: 'copilot-proxy binary missing in lib/bridges/' });
@@ -53,6 +53,30 @@ export function lintDocPresentation(body, { type } = {}) {
53
53
  warnings.push('prd-family: consider FR-* or ## Requirements section');
54
54
  }
55
55
 
56
+ const placeholder = body.match(/\{\{[^}]+\}\}|\[object Object\]|\b(?:TODO|TBD|FIXME|XXX)\b/);
57
+ if (placeholder) {
58
+ errors.push(`unresolved placeholder: ${placeholder[0]}`);
59
+ }
60
+
61
+ const headings = [];
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const match = lines[i].match(/^(#{1,6})\s+(.*)/);
64
+ if (match) headings.push({ index: i, level: match[1].length, text: match[2].trim() });
65
+ }
66
+ for (let h = 0; h < headings.length; h++) {
67
+ const current = headings[h];
68
+ const next = headings[h + 1];
69
+ const end = next ? next.index : lines.length;
70
+ let hasContent = false;
71
+ for (let i = current.index + 1; i < end; i++) {
72
+ if (lines[i].trim() !== '') { hasContent = true; break; }
73
+ }
74
+ if (!hasContent && (!next || next.level <= current.level)) {
75
+ errors.push(`empty section: ${current.text}`);
76
+ break;
77
+ }
78
+ }
79
+
56
80
  return { errors, warnings };
57
81
  }
58
82
 
@@ -99,6 +99,42 @@ export async function refreshContextMd({ rootDir, now = new Date() } = {}) {
99
99
  };
100
100
  }
101
101
 
102
+ const CONTEXT_SCAFFOLD = `# context
103
+
104
+ ${SECTION_HEADERS.activeWork}
105
+
106
+ _None in progress._
107
+
108
+ ${SECTION_HEADERS.recentDecisions}
109
+
110
+ _No recent decisions captured._
111
+
112
+ ${SECTION_HEADERS.architectureNotes}
113
+
114
+ _No new architecture notes._
115
+
116
+ ${SECTION_HEADERS.openQuestions}
117
+
118
+ _None._
119
+ `;
120
+
121
+ /**
122
+ * Re-converge `.cx/context.md` to the canonical scaffold, then refresh the managed sections.
123
+ * Discards user-authored drift, so the operation is gated on explicit consent (decision
124
+ * construct-rr63.3.1): without consent the file is preserved untouched; with consent the file is
125
+ * rewritten to the scaffold and the managed sections are repopulated from current activity. The
126
+ * default preserves — re-converge never happens silently.
127
+ */
128
+ export async function reconvergeContextMd({ rootDir, consent = false, now = new Date() } = {}) {
129
+ if (!rootDir) return { ok: false, reason: 'rootDir-required' };
130
+ const contextMdPath = join(rootDir, '.cx', 'context.md');
131
+ if (!existsSync(contextMdPath)) return { ok: false, reason: 'no-context-md' };
132
+ if (!consent) return { ok: false, reason: 'consent-required', preserved: true };
133
+ writeFileSync(contextMdPath, CONTEXT_SCAFFOLD, 'utf8');
134
+ const refreshed = await refreshContextMd({ rootDir, now });
135
+ return { ok: true, reconverged: true, refreshed: refreshed.ok };
136
+ }
137
+
102
138
  // ---------------------------------------------------------------------------
103
139
  // plan.md sync and archive
104
140
  // ---------------------------------------------------------------------------