@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
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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))
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
if (!allowAmbient)
|
|
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
|
|
162
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/service-manager.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|