@geraldmaron/construct 1.3.2 → 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/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 +11 -4
- package/lib/mcp-platform-config.mjs +18 -1
- 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/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
package/README.md
CHANGED
|
@@ -136,6 +136,7 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
|
|
|
136
136
|
| `construct init` | Project setup (once per repo): scaffold .cx/, AGENTS.md, plan.md, adapters |
|
|
137
137
|
| `construct install` | Machine setup (scoped per ADR-0029): --scope=project\|user\|both, default project |
|
|
138
138
|
| `construct intake` | View and process the active profile's intake queue (queue label varies by profile) |
|
|
139
|
+
| `construct oracle` | Oracle meta-controller — fleet health review and bounded-auto maintenance |
|
|
139
140
|
| `construct recommendations` | View and manage artifact recommendations |
|
|
140
141
|
| `construct sandbox` | Isolated tmpdir-based environment for QA / specialist dry-runs |
|
|
141
142
|
| `construct scope` | Manage the active org scope and its lifecycle (draft, promote, archive, health) |
|
|
@@ -186,7 +187,7 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
|
|
|
186
187
|
| `construct hosts` | Show host support for Construct orchestration |
|
|
187
188
|
| `construct mcp` | Manage MCP integrations |
|
|
188
189
|
| `construct models` | Show or update model tier assignments |
|
|
189
|
-
| `construct orchestrate` | Construct-owned local orchestration runtime
|
|
190
|
+
| `construct orchestrate` | Construct-owned local orchestration runtime and readiness preflight |
|
|
190
191
|
| `construct plugin` | Manage external Construct plugin manifests |
|
|
191
192
|
|
|
192
193
|
### Integrations
|
package/bin/construct
CHANGED
|
@@ -28,6 +28,7 @@ import { runUninstall } from '../lib/uninstall/uninstall.mjs';
|
|
|
28
28
|
import { maybeFirstInvocationProbe } from '../lib/install/first-invocation.mjs';
|
|
29
29
|
import { loadConstructEnv, getUserEnvPath } from '../lib/env-config.mjs';
|
|
30
30
|
import { ensureConstructCredentials } from '../lib/providers/credential-bootstrap.mjs';
|
|
31
|
+
import { enableSecretAuditTrail } from '../lib/providers/secret-audit-wiring.mjs';
|
|
31
32
|
import {
|
|
32
33
|
loadProjectConfig,
|
|
33
34
|
writeProjectConfig,
|
|
@@ -81,6 +82,7 @@ import { loadRegistry, listSpecialists } from '../lib/registry/loader.mjs';
|
|
|
81
82
|
|
|
82
83
|
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
|
83
84
|
const HOME = os.homedir();
|
|
85
|
+
enableSecretAuditTrail();
|
|
84
86
|
ensureConstructCredentials({ env: process.env, cwd: ROOT_DIR, home: HOME });
|
|
85
87
|
const ENV = loadConstructEnv({ rootDir: ROOT_DIR, homeDir: HOME, env: process.env });
|
|
86
88
|
for (const [key, value] of Object.entries(ENV)) {
|
|
@@ -399,7 +401,6 @@ async function cmdDoctor() {
|
|
|
399
401
|
println(`${COLORS.bold}Credential diagnostics${COLORS.reset}`);
|
|
400
402
|
println('');
|
|
401
403
|
|
|
402
|
-
const { execSync } = await import('node:child_process');
|
|
403
404
|
const { homedir } = await import('node:os');
|
|
404
405
|
const { join } = await import('node:path');
|
|
405
406
|
const fs = await import('node:fs');
|
|
@@ -420,7 +421,7 @@ async function cmdDoctor() {
|
|
|
420
421
|
|
|
421
422
|
// process.env
|
|
422
423
|
if (process.env[varName]) {
|
|
423
|
-
sources.push(
|
|
424
|
+
sources.push('process.env (set)');
|
|
424
425
|
}
|
|
425
426
|
|
|
426
427
|
// .env files
|
|
@@ -443,13 +444,7 @@ async function cmdDoctor() {
|
|
|
443
444
|
const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
|
|
444
445
|
const opMatch = content.match(opRe);
|
|
445
446
|
if (opMatch) {
|
|
446
|
-
|
|
447
|
-
const r = execSync(`op read '${opMatch[1]}'`, { encoding: 'utf8', timeout: 5000 });
|
|
448
|
-
sources.push(`${rcPath.replace(homedir(), '~')} → op read ✓ (${r.stdout?.trim()?.slice(0, 8)}…)`);
|
|
449
|
-
} catch (e) {
|
|
450
|
-
const err = (e.stderr || e.message || '').slice(0, 120);
|
|
451
|
-
sources.push(`${rcPath.replace(homedir(), '~')} → op read ✗: ${err}`);
|
|
452
|
-
}
|
|
447
|
+
sources.push(`${rcPath.replace(homedir(), '~')} → op:// reference (run \`op read\` to verify)`);
|
|
453
448
|
}
|
|
454
449
|
// Check for direct export (allow leading whitespace for indented rc files)
|
|
455
450
|
const directRe = new RegExp(`^\\s*export\\s+${varName}=(.+)$`, 'm');
|
|
@@ -464,11 +459,7 @@ async function cmdDoctor() {
|
|
|
464
459
|
println(` ${pip}${COLORS.reset} ${COLORS.bold}${varName}${COLORS.reset}`);
|
|
465
460
|
if (sources.length) {
|
|
466
461
|
for (const s of sources.slice(0, 3)) {
|
|
467
|
-
|
|
468
|
-
println(` ${COLORS.red}⚠${COLORS.reset} ${s}`);
|
|
469
|
-
} else {
|
|
470
|
-
println(` ${s}`);
|
|
471
|
-
}
|
|
462
|
+
println(` ${s}`);
|
|
472
463
|
}
|
|
473
464
|
if (sources.length > 3) println(` … +${sources.length - 3} more`);
|
|
474
465
|
} else {
|
|
@@ -3690,8 +3681,62 @@ async function cmdOrchestrate(args) {
|
|
|
3690
3681
|
const sub = args[0];
|
|
3691
3682
|
const rest = args.slice(1);
|
|
3692
3683
|
const flag = (name) => { const i = rest.indexOf(name); return i !== -1 ? rest[i + 1] : undefined; };
|
|
3684
|
+
const eqFlag = (name) => {
|
|
3685
|
+
const hit = rest.find((arg) => arg.startsWith(`${name}=`));
|
|
3686
|
+
return hit ? hit.slice(name.length + 1) : flag(name);
|
|
3687
|
+
};
|
|
3693
3688
|
const wantsJson = rest.includes('--json') || args.includes('--json');
|
|
3694
3689
|
|
|
3690
|
+
if (sub === 'preflight') {
|
|
3691
|
+
const { buildOrchestrationReadiness, recordOrchestrationReadinessEvent, summarizeOrchestrationReadiness } = await import('../lib/orchestration/readiness.mjs');
|
|
3692
|
+
const { probeStdioMcpTools } = await import('../lib/mcp/stdio-mcp-probe.mjs');
|
|
3693
|
+
const splitCsv = (value) => value ? String(value).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
3694
|
+
const timeoutMs = Number(eqFlag('--timeout-ms') || 8000);
|
|
3695
|
+
let observedTools = splitCsv(eqFlag('--observed-tools'));
|
|
3696
|
+
let reachableTools = splitCsv(eqFlag('--reachable-tools'));
|
|
3697
|
+
let probeError = null;
|
|
3698
|
+
|
|
3699
|
+
if (!rest.includes('--no-probe') && !observedTools && !reachableTools) {
|
|
3700
|
+
try {
|
|
3701
|
+
const tools = await probeStdioMcpTools(process.execPath, [path.join(ROOT_DIR, 'lib', 'mcp', 'server.mjs')], {
|
|
3702
|
+
env: { CX_TOOLKIT_DIR: ROOT_DIR },
|
|
3703
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 8000,
|
|
3704
|
+
});
|
|
3705
|
+
observedTools = tools.map((tool) => tool.name);
|
|
3706
|
+
reachableTools = tools.flatMap((tool) => (
|
|
3707
|
+
tool.name === 'call' ? (tool.inputSchema?.properties?.tool?.enum ?? []) : []
|
|
3708
|
+
));
|
|
3709
|
+
} catch (err) {
|
|
3710
|
+
probeError = err.message || String(err);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
const readiness = buildOrchestrationReadiness({
|
|
3715
|
+
host: eqFlag('--host'),
|
|
3716
|
+
sessionId: eqFlag('--session-id'),
|
|
3717
|
+
observedTools,
|
|
3718
|
+
reachableTools,
|
|
3719
|
+
requiredTools: splitCsv(eqFlag('--required-tools')),
|
|
3720
|
+
clientContractVersion: eqFlag('--client-contract-version'),
|
|
3721
|
+
observationScope: eqFlag('--observation-scope') || (observedTools || reachableTools ? 'host-session' : 'local-config'),
|
|
3722
|
+
probeError,
|
|
3723
|
+
authRequired: rest.includes('--auth-required'),
|
|
3724
|
+
}, { env: process.env, cwd: process.cwd() });
|
|
3725
|
+
let eventResult = null;
|
|
3726
|
+
try { eventResult = recordOrchestrationReadinessEvent(readiness, { homeDir: HOME }); } catch { /* telemetry must not break preflight */ }
|
|
3727
|
+
|
|
3728
|
+
if (wantsJson) {
|
|
3729
|
+
println(JSON.stringify({ ...readiness, eventPath: eventResult?.path ?? null }, null, 2));
|
|
3730
|
+
process.exit(readiness.attached ? 0 : 1);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
println(summarizeOrchestrationReadiness(readiness));
|
|
3734
|
+
println(`Reason: ${readiness.reasonCode}`);
|
|
3735
|
+
println(`Next: ${readiness.nextStep}`);
|
|
3736
|
+
println(`Diagnostic id: ${readiness.diagnosticBundle?.diagnosticId}`);
|
|
3737
|
+
process.exit(readiness.attached ? 0 : 1);
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3695
3740
|
// Thin-client mode: drive the local orchestration daemon over HTTP instead of
|
|
3696
3741
|
// running in-process, proving the engine-as-service contract editors/CI use.
|
|
3697
3742
|
if (rest.includes('--remote')) {
|
|
@@ -3783,9 +3828,10 @@ async function cmdOrchestrate(args) {
|
|
|
3783
3828
|
return;
|
|
3784
3829
|
}
|
|
3785
3830
|
|
|
3786
|
-
println('Usage: construct orchestrate <run|status> [options]');
|
|
3831
|
+
println('Usage: construct orchestrate <run|status|preflight> [options]');
|
|
3787
3832
|
println(' run "<request>" [--workflow-type T] [--strategy S] [--host H] [--host-model M] [--no-construct] [--no-execute] [--json]');
|
|
3788
3833
|
println(' status [run-id] [--json]');
|
|
3834
|
+
println(' preflight [--host H] [--json] [--no-probe] [--observed-tools=a,b] [--reachable-tools=a,b]');
|
|
3789
3835
|
}
|
|
3790
3836
|
|
|
3791
3837
|
async function cmdModels(args) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/a11y-audit.mjs — Per-format accessibility checks with an honest coverage report.
|
|
3
|
+
*
|
|
4
|
+
* Each export format declares the a11y checks that apply to it (contrast, alt text, heading
|
|
5
|
+
* hierarchy, font floor, text extractability) and, just as importantly, the checks that are NOT
|
|
6
|
+
* machine-verifiable for that format — rendered-text contrast in a PDF, reading order in a PPTX.
|
|
7
|
+
* auditAccessibility runs the declared checks and returns both the results and the uncheckable
|
|
8
|
+
* list, so the report never implies coverage it does not have. A missing tool degrades with a
|
|
9
|
+
* typed reason rather than silently passing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { validateBrandContrast } from './brand-contrast.mjs';
|
|
15
|
+
import { auditDeckMarkdownLayout } from './deck-export-pptx.mjs';
|
|
16
|
+
import { whichBin } from './document-export.mjs';
|
|
17
|
+
|
|
18
|
+
// Each format names the checks it runs and the a11y concerns that cannot be machine-verified for
|
|
19
|
+
// it, so the report distinguishes "checked and passed" from "not checkable here".
|
|
20
|
+
|
|
21
|
+
export const FORMAT_A11Y = Object.freeze({
|
|
22
|
+
html: { checks: ['alt_text', 'heading_hierarchy', 'contrast'], uncheckable: [] },
|
|
23
|
+
deck: { checks: ['alt_text', 'heading_hierarchy', 'contrast', 'font_floor'], uncheckable: [{ id: 'reading_order', reason: 'slide reading order is a visual-review judgment' }] },
|
|
24
|
+
pptx: { checks: ['alt_text', 'font_floor'], uncheckable: [{ id: 'contrast', reason: 'rendered-text contrast needs OCR' }, { id: 'reading_order', reason: 'shape reading order is a visual-review judgment' }] },
|
|
25
|
+
pdf: { checks: ['alt_text', 'text_extractable'], uncheckable: [{ id: 'contrast', reason: 'rendered-text contrast needs OCR' }, { id: 'tag_structure', reason: 'Typst/pandoc PDFs are untagged; structure is a manual check' }] },
|
|
26
|
+
docx: { checks: ['alt_text'], uncheckable: [{ id: 'contrast', reason: 'theme contrast resolves in the office client' }] },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function altText(sourceMarkdown) {
|
|
30
|
+
const body = String(sourceMarkdown || '').replace(/```[\s\S]*?```/g, '');
|
|
31
|
+
const missing = [];
|
|
32
|
+
for (const match of body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)) {
|
|
33
|
+
if (!match[1].trim()) missing.push(match[2].split(/\s+/)[0]);
|
|
34
|
+
}
|
|
35
|
+
return missing.length === 0
|
|
36
|
+
? { id: 'alt_text', status: 'pass', detail: 'all images carry alt text' }
|
|
37
|
+
: { id: 'alt_text', status: 'fail', detail: `images without alt text: ${missing.join(', ')}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The branded templates supply the document h1 from the frontmatter title, so a source whose
|
|
41
|
+
// top section level is h2 is well-formed; a first heading of h3 or deeper, or any skipped level,
|
|
42
|
+
// is a real hierarchy break a screen reader cannot recover from.
|
|
43
|
+
|
|
44
|
+
function headingHierarchy(sourceMarkdown) {
|
|
45
|
+
const body = String(sourceMarkdown || '').replace(/^---\n[\s\S]*?\n---\n/, '').replace(/```[\s\S]*?```/g, '');
|
|
46
|
+
const levels = [...body.matchAll(/^(#{1,6})\s+\S/gm)].map((m) => m[1].length);
|
|
47
|
+
if (levels.length === 0) return { id: 'heading_hierarchy', status: 'pass', detail: 'no headings' };
|
|
48
|
+
if (levels[0] > 2) return { id: 'heading_hierarchy', status: 'fail', detail: `first heading is h${levels[0]}; sections should start at h1 or h2` };
|
|
49
|
+
for (let i = 1; i < levels.length; i += 1) {
|
|
50
|
+
if (levels[i] - levels[i - 1] > 1) return { id: 'heading_hierarchy', status: 'fail', detail: `heading jumps from h${levels[i - 1]} to h${levels[i]}` };
|
|
51
|
+
}
|
|
52
|
+
return { id: 'heading_hierarchy', status: 'pass', detail: 'headings start at the top level and never skip' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function contrast() {
|
|
56
|
+
const result = validateBrandContrast();
|
|
57
|
+
return result.ok
|
|
58
|
+
? { id: 'contrast', status: 'pass', detail: 'brand text palette meets WCAG AA' }
|
|
59
|
+
: { id: 'contrast', status: 'fail', detail: `${result.failures.length} brand pair(s) below AA` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fontFloor(sourceMarkdown) {
|
|
63
|
+
const audit = auditDeckMarkdownLayout(sourceMarkdown, {});
|
|
64
|
+
const below = audit.issues.filter((i) => i.code === 'font_below_floor');
|
|
65
|
+
return below.length === 0
|
|
66
|
+
? { id: 'font_floor', status: 'pass', detail: 'no slide implies a sub-floor font' }
|
|
67
|
+
: { id: 'font_floor', status: 'fail', detail: below.map((i) => `slide ${i.slide}`).join(', ') };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function textExtractable(exportPath, env) {
|
|
71
|
+
if (!whichBin('pdftotext', env)) return { id: 'text_extractable', status: 'degraded', degradation: 'missing-dependency', detail: 'pdftotext not available' };
|
|
72
|
+
if (!exportPath || !fs.existsSync(exportPath)) return { id: 'text_extractable', status: 'degraded', degradation: 'skipped-by-policy', detail: 'export not found' };
|
|
73
|
+
const result = spawnSync('pdftotext', [exportPath, '-'], { encoding: 'utf8', env });
|
|
74
|
+
const text = result.status === 0 ? (result.stdout || '').replace(/\s+/g, '') : '';
|
|
75
|
+
return text.length > 20
|
|
76
|
+
? { id: 'text_extractable', status: 'pass', detail: 'PDF carries a real text layer (not image-only)' }
|
|
77
|
+
: { id: 'text_extractable', status: 'fail', detail: 'PDF has little or no extractable text — likely image-only' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function auditAccessibility({ format, exportPath = null, sourceMarkdown = '', env = process.env } = {}) {
|
|
81
|
+
const spec = FORMAT_A11Y[format];
|
|
82
|
+
if (!spec) return { format, ok: true, results: [], coverage: { checked: [], uncheckable: [] }, unsupported: true };
|
|
83
|
+
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const id of spec.checks) {
|
|
86
|
+
if (id === 'alt_text') results.push(altText(sourceMarkdown));
|
|
87
|
+
else if (id === 'heading_hierarchy') results.push(headingHierarchy(sourceMarkdown));
|
|
88
|
+
else if (id === 'contrast') results.push(contrast());
|
|
89
|
+
else if (id === 'font_floor') results.push(fontFloor(sourceMarkdown));
|
|
90
|
+
else if (id === 'text_extractable') results.push(textExtractable(exportPath, env));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const failures = results.filter((r) => r.status === 'fail');
|
|
94
|
+
return {
|
|
95
|
+
format,
|
|
96
|
+
ok: failures.length === 0,
|
|
97
|
+
results,
|
|
98
|
+
failures: failures.map((r) => `${r.id}: ${r.detail}`),
|
|
99
|
+
coverage: {
|
|
100
|
+
checked: results.map((r) => ({ id: r.id, status: r.status })),
|
|
101
|
+
uncheckable: spec.uncheckable,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/artifact-completion-states.mjs — the single ordered completion-state vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* One canonical list, shared by the manifest schema ($defs.completionState), the manifest
|
|
5
|
+
* validator (qualityContract.requiredStates), and — as they land — the workflow report and CLI
|
|
6
|
+
* output. The order is the completion ladder: an artifact carries the highest rung for which it
|
|
7
|
+
* holds re-verifiable evidence. Keep this array byte-identical to the schema enum; the parity
|
|
8
|
+
* test (tests/asset-quality/completion-states.test.mjs) fails if they drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const COMPLETION_STATES = Object.freeze([
|
|
12
|
+
'planned',
|
|
13
|
+
'authored',
|
|
14
|
+
'structurally-valid',
|
|
15
|
+
'source-linted',
|
|
16
|
+
'exported',
|
|
17
|
+
'file-valid',
|
|
18
|
+
'renderable',
|
|
19
|
+
'screenshot-captured',
|
|
20
|
+
'visually-reviewed',
|
|
21
|
+
'accessibility-reviewed',
|
|
22
|
+
'approved',
|
|
23
|
+
'completed',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export function isCompletionState(state) {
|
|
27
|
+
return COMPLETION_STATES.includes(state);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Rank is the ladder position; -1 for an unknown state. Lets callers compare progression
|
|
31
|
+
// (a later rung requires every earlier rung's evidence) without hardcoding the order.
|
|
32
|
+
|
|
33
|
+
export function completionRank(state) {
|
|
34
|
+
return COMPLETION_STATES.indexOf(state);
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/artifact-completion.mjs — Evidence objects and the no-forgery completion ledger.
|
|
3
|
+
*
|
|
4
|
+
* A completion state advances only when backed by a re-verifiable evidence object; a step that
|
|
5
|
+
* could not run records a typed degradation that documents the miss without advancing the ladder.
|
|
6
|
+
* The no-forgery invariant lives in code: no path records a rung without constructing valid
|
|
7
|
+
* evidence for it. Timestamps are injected (null by default) so a run report stays
|
|
8
|
+
* deterministic — the workflow asserts deepEqual across identical runs.
|
|
9
|
+
*/
|
|
10
|
+
import { COMPLETION_STATES, isCompletionState, completionRank } from './artifact-completion-states.mjs';
|
|
11
|
+
|
|
12
|
+
export const DEGRADATION_REASONS = Object.freeze([
|
|
13
|
+
'unavailable-renderer',
|
|
14
|
+
'missing-dependency',
|
|
15
|
+
'unsupported-format',
|
|
16
|
+
'headless-limitation',
|
|
17
|
+
'skipped-by-policy',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function makeEvidence(state, {
|
|
21
|
+
actor,
|
|
22
|
+
artifact = null,
|
|
23
|
+
proof = null,
|
|
24
|
+
digest = null,
|
|
25
|
+
degradation = null,
|
|
26
|
+
reversible = true,
|
|
27
|
+
timestamp = null,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (!isCompletionState(state)) {
|
|
30
|
+
throw new Error(`unknown completion state: ${state} (expected one of ${COMPLETION_STATES.join(', ')})`);
|
|
31
|
+
}
|
|
32
|
+
if (degradation !== null && !DEGRADATION_REASONS.includes(degradation)) {
|
|
33
|
+
throw new Error(`unknown degradation reason: ${degradation}`);
|
|
34
|
+
}
|
|
35
|
+
if (typeof actor !== 'string' || actor.length === 0) {
|
|
36
|
+
throw new Error('evidence requires a non-empty actor');
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze({ state, actor, artifact, proof, digest, degradation, reversible, timestamp });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// recordCompletion is the only way a state enters the ledger; passing anything but a valid
|
|
42
|
+
// evidence object throws, so a rung cannot be claimed without proof for it.
|
|
43
|
+
|
|
44
|
+
export function recordCompletion(ledger, evidence) {
|
|
45
|
+
if (!evidence || typeof evidence !== 'object' || !isCompletionState(evidence.state)) {
|
|
46
|
+
throw new Error('completion requires a valid evidence object');
|
|
47
|
+
}
|
|
48
|
+
return [...ledger, evidence];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The achieved state is the highest-ranked rung with non-degraded evidence; a degraded entry is
|
|
52
|
+
// kept for the record but never lifts the ladder.
|
|
53
|
+
|
|
54
|
+
export function highestState(ledger = []) {
|
|
55
|
+
let best = null;
|
|
56
|
+
let bestRank = -1;
|
|
57
|
+
for (const evidence of ledger) {
|
|
58
|
+
if (evidence.degradation) continue;
|
|
59
|
+
const rank = completionRank(evidence.state);
|
|
60
|
+
if (rank > bestRank) {
|
|
61
|
+
bestRank = rank;
|
|
62
|
+
best = evidence.state;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/artifact-gate-levels.mjs — Declarative gate-level → check-category registry.
|
|
3
|
+
*
|
|
4
|
+
* Each gate level (fast → human-reviewed) requires a cumulative set of check categories. A
|
|
5
|
+
* category is either available (a real check runs today) or pending (no check exists yet — the
|
|
6
|
+
* entry names the bead that will implement it). planGateForLevel partitions a level into what
|
|
7
|
+
* runs now and what is owed, so a higher level never silently passes the checks it cannot yet
|
|
8
|
+
* perform: the pending categories surface with their beads instead. Mapping mirrors
|
|
9
|
+
* docs/notes/research/construct-asset-quality/synthesis/visual-quality-matrix.md.
|
|
10
|
+
*/
|
|
11
|
+
import { GATE_LEVELS } from './artifact-manifest.mjs';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_GATE_LEVEL = 'standard';
|
|
14
|
+
|
|
15
|
+
// Each category records whether a real check exists today; pending categories carry the bead that
|
|
16
|
+
// implements them so a gate plan can name what it is not yet enforcing.
|
|
17
|
+
|
|
18
|
+
export const CHECK_CATEGORIES = Object.freeze({
|
|
19
|
+
'source-lint': { label: 'Source presentation + structure lint', status: 'available' },
|
|
20
|
+
'export-validation': { label: 'Exported file integrity', status: 'pending', bead: 'construct-cuxq.5.2' },
|
|
21
|
+
'content-roundtrip': { label: 'Content-preservation roundtrip', status: 'pending', bead: 'construct-cuxq.5.2' },
|
|
22
|
+
'reference-integrity': { label: 'Missing image / broken link references', status: 'pending', bead: 'construct-cuxq.5.2' },
|
|
23
|
+
'render-screenshot': { label: 'Rendered page/slide screenshot', status: 'pending', bead: 'construct-cuxq.3.3' },
|
|
24
|
+
'font-floor': { label: 'Minimum font size', status: 'pending', bead: 'construct-cuxq.4.2' },
|
|
25
|
+
contrast: { label: 'WCAG AA contrast', status: 'pending', bead: 'construct-cuxq.7.1' },
|
|
26
|
+
'pixel-regression': { label: 'Pixel regression vs golden', status: 'pending', bead: 'construct-cuxq.10.2' },
|
|
27
|
+
'full-a11y': { label: 'Full per-format accessibility', status: 'pending', bead: 'construct-cuxq.8.1' },
|
|
28
|
+
'judgment-review': { label: 'Model/human judgment review', status: 'pending', bead: 'construct-cuxq.3.4' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Levels are cumulative: each adds categories on top of the cheaper level below it. fast carries
|
|
32
|
+
// only source lint so local feedback stays ms-fast.
|
|
33
|
+
|
|
34
|
+
const LEVEL_ADDITIONS = Object.freeze({
|
|
35
|
+
fast: ['source-lint'],
|
|
36
|
+
standard: ['export-validation', 'content-roundtrip', 'reference-integrity'],
|
|
37
|
+
'render-smoke': ['render-screenshot', 'font-floor', 'contrast'],
|
|
38
|
+
'full-certification': ['pixel-regression', 'full-a11y'],
|
|
39
|
+
'human-reviewed': ['judgment-review'],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function categoriesForLevel(level) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const name of GATE_LEVELS) {
|
|
45
|
+
out.push(...LEVEL_ADDITIONS[name]);
|
|
46
|
+
if (name === level) return out;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveGateLevel(qualityContract) {
|
|
52
|
+
const level = qualityContract?.gateLevel;
|
|
53
|
+
return GATE_LEVELS.includes(level) ? level : DEFAULT_GATE_LEVEL;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// planGateForLevel splits a level's required categories into the checks that run today and the
|
|
57
|
+
// ones still owed; a pending category is never reported as running.
|
|
58
|
+
|
|
59
|
+
export function planGateForLevel(level) {
|
|
60
|
+
const resolved = GATE_LEVELS.includes(level) ? level : DEFAULT_GATE_LEVEL;
|
|
61
|
+
const runs = [];
|
|
62
|
+
const pending = [];
|
|
63
|
+
for (const category of categoriesForLevel(resolved)) {
|
|
64
|
+
const meta = CHECK_CATEGORIES[category];
|
|
65
|
+
if (meta?.status === 'available') runs.push(category);
|
|
66
|
+
else pending.push({ category, bead: meta?.bead ?? null });
|
|
67
|
+
}
|
|
68
|
+
return { level: resolved, runs, pending };
|
|
69
|
+
}
|
|
@@ -11,6 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { resolveActiveScope } from './scopes/loader.mjs';
|
|
14
|
+
import { COMPLETION_STATES, isCompletionState } from './artifact-completion-states.mjs';
|
|
14
15
|
|
|
15
16
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
17
|
const EMPTY_MANIFEST = { version: 1, artifacts: {} };
|
|
@@ -21,6 +22,12 @@ const EMPTY_MANIFEST = { version: 1, artifacts: {} };
|
|
|
21
22
|
export const BRAND_CAPABLE_FORMATS = Object.freeze(['pdf', 'docx', 'doc', 'deck', 'pptx', 'html', 'rtf', 'odt', 'epub', 'tex']);
|
|
22
23
|
export const PLAIN_OUTPUT_FORMATS = Object.freeze(['txt', 'md', 'mdx']);
|
|
23
24
|
|
|
25
|
+
// Quality-gate levels run from cheapest source lint to full visual certification; an artifact's
|
|
26
|
+
// qualityContract.gateLevel selects which deterministic checks block. The completion-state
|
|
27
|
+
// vocabulary it references lives in artifact-completion-states.mjs.
|
|
28
|
+
|
|
29
|
+
export const GATE_LEVELS = Object.freeze(['fast', 'standard', 'render-smoke', 'full-certification', 'human-reviewed']);
|
|
30
|
+
|
|
24
31
|
let cached = null;
|
|
25
32
|
let cachedRoot = null;
|
|
26
33
|
|
|
@@ -152,6 +159,10 @@ export function resolveArtifactWorkflowContract(type, {
|
|
|
152
159
|
formats: [...BRAND_CAPABLE_FORMATS, ...PLAIN_OUTPUT_FORMATS],
|
|
153
160
|
branding: 'construct',
|
|
154
161
|
}, configured.outputs);
|
|
162
|
+
const qualityContract = mergeObjects({
|
|
163
|
+
gateLevel: 'standard',
|
|
164
|
+
requiredStates: ['exported'],
|
|
165
|
+
}, configured.qualityContract);
|
|
155
166
|
const validation = mergeObjects({ releaseGate: true }, configured.validation);
|
|
156
167
|
|
|
157
168
|
return {
|
|
@@ -169,6 +180,7 @@ export function resolveArtifactWorkflowContract(type, {
|
|
|
169
180
|
validation,
|
|
170
181
|
releaseGate,
|
|
171
182
|
outputs,
|
|
183
|
+
qualityContract,
|
|
172
184
|
appliedOverrides,
|
|
173
185
|
};
|
|
174
186
|
}
|
|
@@ -197,6 +209,15 @@ export function validateArtifactManifest(manifest) {
|
|
|
197
209
|
if (entry.outputs?.branding !== undefined && !['construct', 'plain'].includes(entry.outputs.branding)) {
|
|
198
210
|
errors.push(`${prefix}.outputs.branding: must be construct or plain`);
|
|
199
211
|
}
|
|
212
|
+
const gateLevel = entry.qualityContract?.gateLevel;
|
|
213
|
+
if (gateLevel !== undefined && !GATE_LEVELS.includes(gateLevel)) {
|
|
214
|
+
errors.push(`${prefix}.qualityContract.gateLevel: must be one of ${GATE_LEVELS.join(', ')}`);
|
|
215
|
+
}
|
|
216
|
+
if (entry.qualityContract?.requiredStates !== undefined
|
|
217
|
+
&& (!Array.isArray(entry.qualityContract.requiredStates)
|
|
218
|
+
|| entry.qualityContract.requiredStates.some((state) => !isCompletionState(state)))) {
|
|
219
|
+
errors.push(`${prefix}.qualityContract.requiredStates: must be completion states (${COMPLETION_STATES.join(', ')})`);
|
|
220
|
+
}
|
|
200
221
|
}
|
|
201
222
|
return errors.length ? { valid: false, errors } : { valid: true, errors: [] };
|
|
202
223
|
}
|
|
@@ -10,7 +10,8 @@ import path from 'node:path';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import { lintDocStructure, lintDocVisuals } from './templates/visual-requirements.mjs';
|
|
12
12
|
import { lintDocPresentationFile } from './templates/doc-presentation.mjs';
|
|
13
|
-
import { getArtifactEntry, resolveToneForArtifact } from './artifact-manifest.mjs';
|
|
13
|
+
import { getArtifactEntry, resolveToneForArtifact, resolveArtifactWorkflowContract } from './artifact-manifest.mjs';
|
|
14
|
+
import { planGateForLevel, resolveGateLevel } from './artifact-gate-levels.mjs';
|
|
14
15
|
import { lintFile } from './comment-lint.mjs';
|
|
15
16
|
import {
|
|
16
17
|
missingRequiredReviewers,
|
|
@@ -203,6 +204,23 @@ export function validateArtifactRelease({
|
|
|
203
204
|
return { ...result, filePath };
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
// Level-aware entry: runs the release gate (the source-lint category that exists today) and
|
|
208
|
+
// attaches the gate plan for the artifact's level, so callers see both what was enforced and the
|
|
209
|
+
// higher-tier categories still owed. The level comes from the artifact's qualityContract unless
|
|
210
|
+
// passed explicitly. Pending categories are reported, never silently treated as passed.
|
|
211
|
+
|
|
212
|
+
export function runGateAtLevel({ filePath, type, level, cwd = process.cwd(), rootDir } = {}) {
|
|
213
|
+
const resolvedType = type || inferArtifactTypeFromPath(filePath, { rootDir: cwd });
|
|
214
|
+
let effectiveLevel = level;
|
|
215
|
+
if (!effectiveLevel) {
|
|
216
|
+
const contract = resolveArtifactWorkflowContract(resolvedType, { rootDir, cwd });
|
|
217
|
+
effectiveLevel = resolveGateLevel(contract?.qualityContract);
|
|
218
|
+
}
|
|
219
|
+
const release = validateArtifactRelease({ filePath, type: resolvedType, cwd, rootDir });
|
|
220
|
+
const plan = planGateForLevel(effectiveLevel);
|
|
221
|
+
return { ...release, gateLevel: plan.level, gatePlan: { runs: plan.runs, pending: plan.pending } };
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
export async function runArtifactValidateCli(args = []) {
|
|
207
225
|
const positional = args.filter((a) => !a.startsWith('--'));
|
|
208
226
|
const filePath = positional[0];
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { inferArtifactTypeFromPath } from './artifact-type-from-path.mjs';
|
|
17
17
|
import { validateArtifactRelease } from './artifact-release-gate.mjs';
|
|
18
18
|
import { exportMarkdown } from './document-export.mjs';
|
|
19
|
+
import { makeEvidence, recordCompletion, highestState } from './artifact-completion.mjs';
|
|
19
20
|
|
|
20
21
|
function escapeRegex(value) {
|
|
21
22
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -178,6 +179,8 @@ export function runArtifactWorkflow(request = {}, { cwd = process.cwd(), rootDir
|
|
|
178
179
|
skippedSteps: [],
|
|
179
180
|
producedFiles: [],
|
|
180
181
|
validation: null,
|
|
182
|
+
completion: [],
|
|
183
|
+
completionState: null,
|
|
181
184
|
appliedOverrides: plan.appliedOverrides,
|
|
182
185
|
};
|
|
183
186
|
if (plan.status !== 'planned') return report;
|
|
@@ -235,10 +238,22 @@ export function runArtifactWorkflow(request = {}, { cwd = process.cwd(), rootDir
|
|
|
235
238
|
if (!result.ok) {
|
|
236
239
|
report.status = 'export-failed';
|
|
237
240
|
report.skippedSteps.push({ id: step.id, reason: result.message });
|
|
241
|
+
report.completion = recordCompletion(report.completion, makeEvidence('exported', {
|
|
242
|
+
actor: 'construct-export',
|
|
243
|
+
artifact: sourcePath,
|
|
244
|
+
degradation: result.missing?.length ? 'missing-dependency' : 'unsupported-format',
|
|
245
|
+
proof: { format: step.format, message: result.message },
|
|
246
|
+
}));
|
|
247
|
+
report.completionState = highestState(report.completion);
|
|
238
248
|
return report;
|
|
239
249
|
}
|
|
240
250
|
report.executedSteps.push({ id: step.id, result: 'produced', format: step.format });
|
|
241
251
|
report.producedFiles.push({ path: result.outputPath, format: step.format, branding: result.branding });
|
|
252
|
+
report.completion = recordCompletion(report.completion, makeEvidence('exported', {
|
|
253
|
+
actor: 'construct-export',
|
|
254
|
+
artifact: result.outputPath,
|
|
255
|
+
proof: { format: step.format, branding: result.branding },
|
|
256
|
+
}));
|
|
242
257
|
const brandStep = report.skippedSteps.findIndex((entry) => entry.id === 'brand');
|
|
243
258
|
if (brandStep >= 0 && result.branding?.applied === 'construct') {
|
|
244
259
|
report.skippedSteps.splice(brandStep, 1);
|
|
@@ -248,5 +263,6 @@ export function runArtifactWorkflow(request = {}, { cwd = process.cwd(), rootDir
|
|
|
248
263
|
}
|
|
249
264
|
}
|
|
250
265
|
report.status = report.executedSteps.length ? 'completed-local-steps' : 'planned';
|
|
266
|
+
report.completionState = highestState(report.completion);
|
|
251
267
|
return report;
|
|
252
268
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/brand-contrast.mjs — WCAG contrast over the Construct brand palette.
|
|
3
|
+
*
|
|
4
|
+
* Computes WCAG 2.1 relative-luminance contrast ratios between brand colors and checks them
|
|
5
|
+
* against the AA thresholds (4.5:1 for body text, 3:1 for large text and UI). validateBrandContrast
|
|
6
|
+
* runs a declared set of text-bearing ink-on-surface pairs so a palette change that drops legibility
|
|
7
|
+
* fails a test instead of shipping unverified. Mirrors the gap in
|
|
8
|
+
* docs/notes/research/construct-asset-quality/subagents/branding-typography-spacing.md.
|
|
9
|
+
*/
|
|
10
|
+
import { BRAND_TOKENS } from './brand-tokens.mjs';
|
|
11
|
+
|
|
12
|
+
export const AA_BODY = 4.5;
|
|
13
|
+
export const AA_LARGE = 3.0;
|
|
14
|
+
|
|
15
|
+
function channel(value) {
|
|
16
|
+
const c = value / 255;
|
|
17
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function relativeLuminance(hex) {
|
|
21
|
+
const clean = hex.replace('#', '');
|
|
22
|
+
const r = parseInt(clean.slice(0, 2), 16);
|
|
23
|
+
const g = parseInt(clean.slice(2, 4), 16);
|
|
24
|
+
const b = parseInt(clean.slice(4, 6), 16);
|
|
25
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function contrastRatio(fg, bg) {
|
|
29
|
+
const l1 = relativeLuminance(fg);
|
|
30
|
+
const l2 = relativeLuminance(bg);
|
|
31
|
+
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
|
|
32
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function meetsWcagAA(ratio, { large = false } = {}) {
|
|
36
|
+
return ratio >= (large ? AA_LARGE : AA_BODY);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The text-bearing pairs that must stay legible: body and strong ink against every surface they
|
|
40
|
+
// land on, and muted secondary text held to body AA. ink.faint is a decorative color measuring
|
|
41
|
+
// 2.86:1 on paper (below large-text AA), excluded here and guarded by the test as decorative-only
|
|
42
|
+
// rather than promoted to a text color.
|
|
43
|
+
|
|
44
|
+
export const BRAND_TEXT_PAIRS = Object.freeze([
|
|
45
|
+
{ label: 'ink.default on paper', fg: BRAND_TOKENS.ink.default, bg: BRAND_TOKENS.surface.paper, large: false },
|
|
46
|
+
{ label: 'ink.strong on paper', fg: BRAND_TOKENS.ink.strong, bg: BRAND_TOKENS.surface.paper, large: false },
|
|
47
|
+
{ label: 'ink.body on paper', fg: BRAND_TOKENS.ink.body, bg: BRAND_TOKENS.surface.paper, large: false },
|
|
48
|
+
{ label: 'ink.body on surface', fg: BRAND_TOKENS.ink.body, bg: BRAND_TOKENS.surface.default, large: false },
|
|
49
|
+
{ label: 'ink.body on surfaceAlt', fg: BRAND_TOKENS.ink.body, bg: BRAND_TOKENS.surface.alt, large: false },
|
|
50
|
+
{ label: 'ink.muted on paper', fg: BRAND_TOKENS.ink.muted, bg: BRAND_TOKENS.surface.paper, large: false },
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export function validateBrandContrast(pairs = BRAND_TEXT_PAIRS) {
|
|
54
|
+
const results = pairs.map((pair) => {
|
|
55
|
+
const ratio = contrastRatio(pair.fg, pair.bg);
|
|
56
|
+
return { ...pair, ratio: Math.round(ratio * 100) / 100, pass: meetsWcagAA(ratio, { large: pair.large }) };
|
|
57
|
+
});
|
|
58
|
+
const failures = results.filter((result) => !result.pass);
|
|
59
|
+
return { ok: failures.length === 0, results, failures };
|
|
60
|
+
}
|
package/lib/brand-tokens.mjs
CHANGED
|
@@ -31,6 +31,20 @@ export const STATUS = Object.freeze({
|
|
|
31
31
|
danger: '#e06c75',
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
// One spacing scale as base-unit multiples so PDF (Typst em), HTML/CSS (rem), and templates derive
|
|
35
|
+
// from a single rhythm instead of drifting with ad hoc per-surface values. Consumers multiply by
|
|
36
|
+
// their own base unit.
|
|
37
|
+
|
|
38
|
+
export const SPACING_SCALE = Object.freeze({
|
|
39
|
+
none: 0,
|
|
40
|
+
xs: 0.25,
|
|
41
|
+
sm: 0.5,
|
|
42
|
+
md: 1,
|
|
43
|
+
lg: 1.5,
|
|
44
|
+
xl: 2,
|
|
45
|
+
xxl: 3,
|
|
46
|
+
});
|
|
47
|
+
|
|
34
48
|
export const BRAND_TOKENS = Object.freeze({
|
|
35
49
|
ink: Object.freeze({
|
|
36
50
|
default: INK.ink,
|