@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
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, in-process or against the local daemon (--remote) |
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(`process.env (${process.env[varName].slice(0, 8)}…)`);
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
- try {
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
- if (s.includes('op read ✗')) {
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
+ }
@@ -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,