@geraldmaron/construct 1.0.24 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +2 -0
  2. package/bin/construct +266 -13
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/audit-trail.mjs +77 -10
  7. package/lib/beads-client.mjs +9 -0
  8. package/lib/beads-optimistic.mjs +23 -71
  9. package/lib/bridges/copilot-proxy.mjs +116 -0
  10. package/lib/cli-commands.mjs +112 -1
  11. package/lib/comment-lint.mjs +1 -1
  12. package/lib/config/schema.mjs +1 -1
  13. package/lib/demo.mjs +245 -0
  14. package/lib/diagram.mjs +300 -0
  15. package/lib/doctor/index.mjs +1 -1
  16. package/lib/doctor/watchers/process-pressure.mjs +1 -1
  17. package/lib/doctor/watchers/service-health.mjs +1 -1
  18. package/lib/document-extract/docling-client.mjs +16 -6
  19. package/lib/document-extract/docling-sidecar.py +32 -2
  20. package/lib/document-extract.mjs +85 -10
  21. package/lib/document-ingest.mjs +97 -7
  22. package/lib/embed/roadmap.mjs +16 -1
  23. package/lib/embed/semantic.mjs +5 -2
  24. package/lib/engine/consolidate.mjs +160 -3
  25. package/lib/engine/contradiction-judge.mjs +71 -0
  26. package/lib/engine/contradiction.mjs +74 -0
  27. package/lib/handoffs/cleanup.mjs +1 -1
  28. package/lib/hooks/audit-trail.mjs +14 -28
  29. package/lib/host-capabilities.mjs +30 -0
  30. package/lib/ingest/docling-remote.mjs +90 -0
  31. package/lib/ingest/strategy.mjs +1 -1
  32. package/lib/init-unified.mjs +9 -13
  33. package/lib/logging/rotate.mjs +18 -0
  34. package/lib/mcp/server.mjs +124 -12
  35. package/lib/mcp/tool-budget.mjs +53 -0
  36. package/lib/mcp-catalog.json +1 -1
  37. package/lib/model-router.mjs +52 -1
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration-policy.mjs +41 -6
  43. package/lib/persona-sections.mjs +66 -0
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +14 -9
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reflect/extractor.mjs +14 -1
  48. package/lib/reflect/salience.mjs +65 -0
  49. package/lib/rules-delivery.mjs +122 -0
  50. package/lib/runtime/uv-bootstrap.mjs +32 -17
  51. package/lib/server/index.mjs +1 -1
  52. package/lib/server/langfuse-login.mjs +3 -3
  53. package/lib/service-manager.mjs +42 -4
  54. package/lib/session-store.mjs +1 -1
  55. package/lib/setup.mjs +58 -2
  56. package/lib/specialists/prompt-schema.mjs +162 -0
  57. package/lib/specialists/scaffold.mjs +109 -0
  58. package/lib/storage/embeddings-engine.mjs +19 -5
  59. package/lib/storage/embeddings-local.mjs +6 -3
  60. package/lib/storage/file-lock.mjs +18 -11
  61. package/lib/telemetry/beads-fallback.mjs +40 -0
  62. package/lib/telemetry/hook-calls.mjs +138 -0
  63. package/package.json +4 -2
  64. package/personas/construct.md +12 -12
  65. package/platforms/capabilities.json +76 -0
  66. package/platforms/opencode/sync-config.mjs +121 -25
  67. package/rules/common/neurodivergent-output.md +1 -1
  68. package/rules/web/coding-style.md +8 -0
  69. package/rules/web/design-quality.md +8 -0
  70. package/rules/web/hooks.md +8 -0
  71. package/rules/web/patterns.md +8 -0
  72. package/rules/web/performance.md +8 -0
  73. package/rules/web/security.md +8 -0
  74. package/rules/web/testing.md +8 -0
  75. package/scripts/sync-specialists.mjs +277 -63
  76. package/skills/docs/init-project.md +1 -1
  77. package/specialists/prompts/cx-architect.md +20 -0
  78. package/specialists/prompts/cx-test-automation.md +12 -0
  79. package/templates/docs/construct_guide.md +2 -2
  80. package/lib/ingest/chunker.mjs +0 -94
  81. package/lib/ingest/pipeline.mjs +0 -53
  82. package/lib/ingest/store.mjs +0 -82
  83. package/lib/mode-commands.mjs +0 -122
  84. package/lib/policy/unified-gates.mjs +0 -96
  85. package/lib/profiles/validate-custom.mjs +0 -114
  86. package/lib/services/telemetry-backend.mjs +0 -177
  87. package/lib/storage/fusion.mjs +0 -95
@@ -1,94 +0,0 @@
1
- /**
2
- * lib/ingest/chunker.mjs — paragraph-boundary chunker with sentence overlap.
3
- *
4
- * 2026-06 best practice notes:
5
- * - Recursive/sentence chunking remains the high-recall default (~85-90%
6
- * on 2026 RAG benchmarks). Semantic chunking gains only ~2-3% recall at
7
- * ~14× the embedding cost and only matters above ~5k token docs.
8
- * - Parent-document retrieval is the meaningful 2026 upgrade beyond
9
- * paragraph chunking — track separately if recall plateaus.
10
- *
11
- * Strategy:
12
- * 1. Split markdown by blank-line paragraph boundaries.
13
- * 2. Pack paragraphs into chunks under maxChars (default 1500 ≈ 375 tokens).
14
- * 3. Add 1-2 sentence overlap between consecutive chunks to preserve
15
- * cross-chunk anchors.
16
- * 4. Preserve markdown structure: never split inside a fenced code block.
17
- */
18
-
19
- const DEFAULT_MAX_CHARS = 1500;
20
- const DEFAULT_OVERLAP_SENTENCES = 2;
21
- const SENTENCE_RE = /[^.!?]+[.!?]+["')\]]*\s*/g;
22
-
23
- export function splitSentences(text) {
24
- const matches = text.match(SENTENCE_RE);
25
- if (matches && matches.length) return matches.map((s) => s.trim()).filter(Boolean);
26
- return text.split('\n').map((s) => s.trim()).filter(Boolean);
27
- }
28
-
29
- function tailSentences(text, count) {
30
- const sentences = splitSentences(text);
31
- return sentences.slice(-count).join(' ');
32
- }
33
-
34
- function splitParagraphs(markdown) {
35
- const blocks = [];
36
- let buffer = '';
37
- let inFence = false;
38
- for (const line of markdown.split('\n')) {
39
- if (/^```/.test(line.trim())) inFence = !inFence;
40
- if (!inFence && line.trim() === '' && buffer.trim()) {
41
- blocks.push(buffer.trim());
42
- buffer = '';
43
- continue;
44
- }
45
- buffer += line + '\n';
46
- }
47
- if (buffer.trim()) blocks.push(buffer.trim());
48
- return blocks;
49
- }
50
-
51
- export function chunkMarkdown(markdown, { maxChars = DEFAULT_MAX_CHARS, overlapSentences = DEFAULT_OVERLAP_SENTENCES } = {}) {
52
- const paragraphs = splitParagraphs(markdown || '');
53
- if (paragraphs.length === 0) return [];
54
-
55
- const chunks = [];
56
- let current = '';
57
- let chunkIndex = 0;
58
- const flushChunk = () => {
59
- if (!current.trim()) return;
60
- const text = current.trim();
61
- chunks.push({
62
- index: chunkIndex++,
63
- text,
64
- chars: text.length,
65
- });
66
- const overlap = overlapSentences > 0 ? tailSentences(text, overlapSentences) : '';
67
- current = overlap ? overlap + '\n\n' : '';
68
- };
69
-
70
- for (const para of paragraphs) {
71
- if (current.length + para.length + 2 > maxChars && current.trim()) {
72
- flushChunk();
73
- }
74
- if (para.length > maxChars) {
75
- flushChunk();
76
- const sentences = splitSentences(para);
77
- let buf = '';
78
- for (const s of sentences) {
79
- if (buf.length + s.length + 1 > maxChars && buf.trim()) {
80
- chunks.push({ index: chunkIndex++, text: buf.trim(), chars: buf.trim().length });
81
- buf = (overlapSentences > 0 ? tailSentences(buf, overlapSentences) : '') + (overlapSentences > 0 ? ' ' : '');
82
- }
83
- buf += s + ' ';
84
- }
85
- if (buf.trim()) {
86
- current = buf.trim() + '\n\n';
87
- }
88
- continue;
89
- }
90
- current += (current ? '\n\n' : '') + para;
91
- }
92
- flushChunk();
93
- return chunks;
94
- }
@@ -1,53 +0,0 @@
1
- /**
2
- * lib/ingest/pipeline.mjs — orchestrate the content-hashed ingest pipeline.
3
- *
4
- * Flow: hash → de-dup → extract via docling/whisper → chunk → write to
5
- * .cx/ingest/<sha>/. Returns a record describing what was stored along
6
- * with any droppedInfo surfaced by the extractor.
7
- *
8
- * Idempotent: re-ingesting the same content (by sha256) returns the
9
- * existing record without re-extracting.
10
- */
11
- import fs from 'node:fs/promises';
12
- import path from 'node:path';
13
- import { extractDocumentTextAsync } from '../document-extract.mjs';
14
- import { hashFile, defaultIngestRoot, readRecord, writeRecord } from './store.mjs';
15
- import { chunkMarkdown } from './chunker.mjs';
16
-
17
- export async function ingestFile(filePath, { cwd = process.cwd(), force = false } = {}) {
18
- const absPath = path.resolve(filePath);
19
- const stat = await fs.stat(absPath);
20
- if (!stat.isFile()) throw new Error(`not a regular file: ${absPath}`);
21
-
22
- const root = defaultIngestRoot(cwd);
23
- const sha256 = await hashFile(absPath);
24
-
25
- if (!force) {
26
- const existing = await readRecord(root, sha256);
27
- if (existing) return { ...existing, status: 'cached' };
28
- }
29
-
30
- const extracted = await extractDocumentTextAsync(absPath);
31
- const markdown = extracted.markdown ?? extracted.text ?? '';
32
- const chunks = chunkMarkdown(markdown);
33
-
34
- const source = {
35
- sourcePath: absPath,
36
- fileName: path.basename(absPath),
37
- extension: extracted.extension,
38
- bytes: stat.size,
39
- sha256,
40
- ingestedAt: new Date().toISOString(),
41
- };
42
-
43
- const meta = {
44
- extractionMethod: extracted.extractionMethod,
45
- extractorMetadata: extracted.metadata ?? null,
46
- droppedInfo: extracted.droppedInfo ?? [],
47
- chunkCount: chunks.length,
48
- chunkChars: chunks.reduce((a, c) => a + c.chars, 0),
49
- };
50
-
51
- const record = await writeRecord(root, { sha256, source, meta, markdown });
52
- return { ...record, status: 'ingested', droppedInfo: meta.droppedInfo, chunks };
53
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * lib/ingest/store.mjs — content-addressed ingest store at .cx/ingest/<hash>/.
3
- *
4
- * Each ingested document is stored under its SHA-256 content hash:
5
- * .cx/ingest/<sha256>/source.json — original path, size, sha256, ingestedAt
6
- * .cx/ingest/<sha256>/markdown.md — extracted markdown body
7
- * .cx/ingest/<sha256>/meta.json — extractor metadata, droppedInfo, chunks
8
- *
9
- * Idempotent: re-ingesting a file with the same content is a no-op (returns
10
- * the existing record). Re-running ingestion is the safe default.
11
- *
12
- * 2026-06 best practice notes:
13
- * - SHA-256 is still the universal content-addressing primitive. BLAKE3 is
14
- * ~2.3× faster on M-series and worth considering at multi-GB scale,
15
- * but is not the industry default and adds an unfamiliar dependency.
16
- * Revisit if ingest throughput becomes a measured bottleneck.
17
- * - Storing markdown body separately from metadata keeps the embedding
18
- * and search paths simple: they only read markdown.md and look up
19
- * provenance from meta.json on demand.
20
- */
21
- import { createHash } from 'node:crypto';
22
- import { createReadStream } from 'node:fs';
23
- import fs from 'node:fs/promises';
24
- import path from 'node:path';
25
-
26
- export function defaultIngestRoot(cwd = process.cwd()) {
27
- return path.join(cwd, '.cx', 'ingest');
28
- }
29
-
30
- export async function hashFile(filePath) {
31
- return new Promise((resolveHash, reject) => {
32
- const hash = createHash('sha256');
33
- const stream = createReadStream(filePath);
34
- stream.on('data', (chunk) => hash.update(chunk));
35
- stream.on('error', reject);
36
- stream.on('end', () => resolveHash(hash.digest('hex')));
37
- });
38
- }
39
-
40
- function recordDirFor(root, sha256) {
41
- return path.join(root, sha256);
42
- }
43
-
44
- export async function readRecord(root, sha256) {
45
- const dir = recordDirFor(root, sha256);
46
- try {
47
- const [source, meta, markdown] = await Promise.all([
48
- fs.readFile(path.join(dir, 'source.json'), 'utf8').then(JSON.parse),
49
- fs.readFile(path.join(dir, 'meta.json'), 'utf8').then(JSON.parse),
50
- fs.readFile(path.join(dir, 'markdown.md'), 'utf8'),
51
- ]);
52
- return { sha256, source, meta, markdown, dir };
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- export async function writeRecord(root, { sha256, source, meta, markdown }) {
59
- const dir = recordDirFor(root, sha256);
60
- await fs.mkdir(dir, { recursive: true });
61
- await Promise.all([
62
- fs.writeFile(path.join(dir, 'source.json'), JSON.stringify(source, null, 2) + '\n', 'utf8'),
63
- fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8'),
64
- fs.writeFile(path.join(dir, 'markdown.md'), markdown, 'utf8'),
65
- ]);
66
- return { sha256, dir, source, meta, markdown };
67
- }
68
-
69
- export async function listRecords(root) {
70
- let entries;
71
- try { entries = await fs.readdir(root, { withFileTypes: true }); }
72
- catch { return []; }
73
- const records = [];
74
- for (const entry of entries) {
75
- if (!entry.isDirectory()) continue;
76
- const sha = entry.name;
77
- if (!/^[a-f0-9]{64}$/.test(sha)) continue;
78
- const record = await readRecord(root, sha);
79
- if (record) records.push(record);
80
- }
81
- return records;
82
- }
@@ -1,122 +0,0 @@
1
- /**
2
- * lib/mode-commands.mjs — Standardized command contracts across deployment modes.
3
- *
4
- * Provides mode-aware command execution for intake, memory, and workflow operations.
5
- * Routes to file-based implementations in solo mode, Postgres in team/enterprise mode.
6
- */
7
-
8
- // lib/mode-commands.mjs
9
- // Standardized command contracts across deployment modes
10
-
11
- import { spawnSync } from 'node:child_process';
12
- import { existsSync, readFileSync } from 'node:fs';
13
- import { basename, join } from 'node:path';
14
-
15
- export const MODE_COMMANDS = {
16
- // Intake queue operations
17
- intake: {
18
- list: async (options = {}) => {
19
- const mode = getDeploymentMode();
20
-
21
- if (mode === 'solo') {
22
- // File-based intake
23
- const result = spawnSync('find', ['.cx/intake/pending', '-name', '*.json', '-type', 'f'],
24
- { encoding: 'utf8', cwd: options.cwd });
25
- return parseIntakeFiles(result.stdout);
26
- } else {
27
- // Postgres-based intake
28
- return queryPostgresIntake(options);
29
- }
30
- },
31
-
32
- show: async (id, options = {}) => {
33
- const mode = getDeploymentMode();
34
-
35
- if (mode === 'solo') {
36
- return readIntakeFile(id, options);
37
- } else {
38
- return queryPostgresIntakeItem(id, options);
39
- }
40
- },
41
- },
42
-
43
- // Storage operations
44
- storage: {
45
- sync: async (options = {}) => {
46
- const mode = getDeploymentMode();
47
-
48
- // Same interface, different implementation
49
- if (mode === 'solo') {
50
- return syncToFileStorage(options);
51
- } else {
52
- return syncToPostgresStorage(options);
53
- }
54
- },
55
-
56
- query: async (criteria, options = {}) => {
57
- // Unified query interface
58
- return queryStorage(criteria, options);
59
- },
60
- },
61
- };
62
-
63
- function getDeploymentMode() {
64
- return process.env.CONSTRUCT_DEPLOYMENT_MODE || 'solo';
65
- }
66
-
67
- function parseIntakeFiles(stdout) {
68
- return stdout.split('\n')
69
- .filter(Boolean)
70
- .map(f => ({ id: basename(f, '.json'), source: 'file' }));
71
- }
72
-
73
- function readIntakeFile(id, options) {
74
- const filePath = join(options.cwd || process.cwd(), '.cx/intake/pending', `${id}.json`);
75
- if (!existsSync(filePath)) {
76
- return { error: `Intake item ${id} not found` };
77
- }
78
- return JSON.parse(readFileSync(filePath, 'utf8'));
79
- }
80
-
81
- async function queryPostgresIntake(options) {
82
- // Would use SQL client
83
- return { items: [], mode: 'postgres' };
84
- }
85
-
86
- async function queryPostgresIntakeItem(id, options) {
87
- return { id, mode: 'postgres' };
88
- }
89
-
90
- async function syncToFileStorage(options) {
91
- return {
92
- success: true,
93
- mode: 'solo',
94
- backend: 'file',
95
- };
96
- }
97
-
98
- async function syncToPostgresStorage(options) {
99
- return {
100
- success: true,
101
- mode: 'team',
102
- backend: 'postgres',
103
- };
104
- }
105
-
106
- async function queryStorage(criteria, options) {
107
- // Unified query - works across modes
108
- return {
109
- results: [],
110
- mode: getDeploymentMode(),
111
- };
112
- }
113
-
114
- export function getCommandInterface(command, operation) {
115
- const cmd = MODE_COMMANDS[command];
116
- if (!cmd) return null;
117
-
118
- const fn = cmd[operation];
119
- if (!fn) return null;
120
-
121
- return fn;
122
- }
@@ -1,96 +0,0 @@
1
- // lib/policy/unified-gates.mjs
2
- // Single policy declaration for all enforcement layers
3
-
4
- export const POLICIES = {
5
- commentStyle: {
6
- id: 'comment-style',
7
- description: 'Comments must follow project standards',
8
- layers: ['write', 'commit', 'ci'],
9
- bypass: 'CONSTRUCT_SKIP_COMMENT_STYLE',
10
- critical: false,
11
- },
12
- docCoupling: {
13
- id: 'doc-coupling',
14
- description: 'Code changes require documentation updates',
15
- layers: ['commit', 'push'],
16
- bypass: 'CONSTRUCT_SKIP_DOCS',
17
- critical: false,
18
- },
19
- secretScan: {
20
- id: 'secret-scan',
21
- description: 'No secrets in committed code',
22
- layers: ['commit', 'push', 'ci'],
23
- bypass: 'CONSTRUCT_SKIP_SECRET_SCAN',
24
- critical: true,
25
- },
26
- ciStatus: {
27
- id: 'ci-status',
28
- description: 'CI must be green before push',
29
- layers: ['push', 'session-end'],
30
- bypass: 'CONSTRUCT_SKIP_CI_CHECK',
31
- critical: true,
32
- },
33
- };
34
-
35
- export class UnifiedGateEngine {
36
- constructor(options = {}) {
37
- this.env = options.env || process.env;
38
- }
39
-
40
- isBypassed(policyId) {
41
- const policy = POLICIES[policyId];
42
- if (!policy) return { bypassed: false };
43
-
44
- if (policy.bypass && this.env[policy.bypass] === '1') {
45
- return { bypassed: true, envVar: policy.bypass };
46
- }
47
-
48
- if (this.env.CONSTRUCT_SKIP_ALL_GATES === '1') {
49
- return { bypassed: true, envVar: 'CONSTRUCT_SKIP_ALL_GATES' };
50
- }
51
-
52
- return { bypassed: false };
53
- }
54
-
55
- async evaluateLayer(layer, context = {}) {
56
- const results = [];
57
-
58
- for (const [id, policy] of Object.entries(POLICIES)) {
59
- if (policy.layers.includes(layer)) {
60
- const bypass = this.isBypassed(id);
61
- results.push({
62
- policy: id,
63
- passed: bypass.bypassed || true,
64
- bypassed: bypass.bypassed,
65
- bypassEnvVar: bypass.envVar,
66
- critical: policy.critical,
67
- });
68
- }
69
- }
70
-
71
- const failed = results.filter(r => !r.passed && r.critical);
72
-
73
- return {
74
- layer,
75
- passed: failed.length === 0,
76
- canProceed: failed.length === 0,
77
- results,
78
- summary: `${results.filter(r => r.passed).length}/${results.length} passed`,
79
- };
80
- }
81
- }
82
-
83
- export async function checkGates(layer, context = {}, options = {}) {
84
- const engine = new UnifiedGateEngine(options);
85
- return await engine.evaluateLayer(layer, context);
86
- }
87
-
88
- export function listPolicies() {
89
- return Object.entries(POLICIES).map(([id, policy]) => ({
90
- id,
91
- description: policy.description,
92
- layers: policy.layers,
93
- bypass: policy.bypass,
94
- critical: policy.critical,
95
- }));
96
- }
@@ -1,114 +0,0 @@
1
- /**
2
- * lib/profiles/validate-custom.mjs — Schema validator for user-defined profiles.
3
- *
4
- * Custom profiles live at `<project>/.cx/profile.json` with `custom: true`.
5
- * They are validated on `construct sync` and via the pre-push gate. Rejected
6
- * profiles never get loaded; resolveActiveProfile falls back to the default
7
- * so a malformed escape-hatch file never breaks a project.
8
- *
9
- * Hard limits the validator enforces (kept in sync with schemas/profile.schema.json
10
- * and scripts/lint-profiles.mjs):
11
- * - max 12 stages
12
- * - max 24 intake types
13
- * - max 80 roles per profile
14
- * - max 12 departments per profile
15
- * - max 20 roles per department
16
- * - classificationTable path must stay inside .cx/ (no repo escapes)
17
- *
18
- * Rationale for each cap lives in docs/concepts/persona-research.md.
19
- */
20
- import fs from 'node:fs';
21
- import path from 'node:path';
22
-
23
- const MAX_STAGES = 12;
24
- const MAX_INTAKE_TYPES = 24;
25
- const MAX_ROLES = 80;
26
- const MAX_DEPARTMENTS = 12;
27
- const MAX_ROLES_PER_DEPARTMENT = 20;
28
-
29
- /**
30
- * @returns {string[]} array of error strings; empty means valid.
31
- */
32
- export function validateCustomProfile(profile, { cwd } = {}) {
33
- const errors = [];
34
- if (!profile || typeof profile !== 'object') return ['profile is not an object'];
35
- if (profile.custom !== true) errors.push('custom profiles must set custom: true');
36
-
37
- if (!profile.id || !/^[a-z][a-z0-9-]{1,30}$/.test(profile.id)) {
38
- errors.push('id must match ^[a-z][a-z0-9-]{1,30}$');
39
- }
40
- if (!profile.displayName || typeof profile.displayName !== 'string') {
41
- errors.push('displayName is required');
42
- }
43
- if (!Array.isArray(profile.roles) || profile.roles.length === 0) {
44
- errors.push('roles must be a non-empty array');
45
- } else if (profile.roles.length > MAX_ROLES) {
46
- errors.push(`roles exceeds max of ${MAX_ROLES}`);
47
- }
48
- if (!profile.intake || typeof profile.intake !== 'object') {
49
- errors.push('intake is required');
50
- } else {
51
- if (!Array.isArray(profile.intake.types) || profile.intake.types.length === 0) {
52
- errors.push('intake.types must be a non-empty array');
53
- } else if (profile.intake.types.length > MAX_INTAKE_TYPES) {
54
- errors.push(`intake.types exceeds max of ${MAX_INTAKE_TYPES}`);
55
- }
56
- if (!Array.isArray(profile.intake.stages) || profile.intake.stages.length === 0) {
57
- errors.push('intake.stages must be a non-empty array');
58
- } else if (profile.intake.stages.length > MAX_STAGES) {
59
- errors.push(`intake.stages exceeds max of ${MAX_STAGES}`);
60
- }
61
- if (typeof profile.intake.classificationTable === 'string') {
62
- const t = profile.intake.classificationTable;
63
- if (path.isAbsolute(t)) {
64
- errors.push('intake.classificationTable must be a relative path');
65
- } else if (cwd && !t.startsWith('.cx/')) {
66
- errors.push('intake.classificationTable must live under .cx/ for custom profiles');
67
- }
68
- }
69
- }
70
- if (profile.departments !== undefined) {
71
- if (!Array.isArray(profile.departments)) {
72
- errors.push('departments must be an array');
73
- } else {
74
- if (profile.departments.length > MAX_DEPARTMENTS) {
75
- errors.push(`departments exceeds max of ${MAX_DEPARTMENTS}`);
76
- }
77
- for (const [i, dept] of profile.departments.entries()) {
78
- if (!dept || typeof dept !== 'object') {
79
- errors.push(`departments[${i}] must be an object`);
80
- continue;
81
- }
82
- if (!dept.id || !/^[a-z][a-z0-9-]{1,40}$/.test(dept.id)) {
83
- errors.push(`departments[${i}].id is missing or malformed`);
84
- }
85
- if (!dept.charter || dept.charter.length < 20) {
86
- errors.push(`departments[${i}].charter must be at least 20 chars (mission statement, not a label)`);
87
- }
88
- if (!Array.isArray(dept.roles) || dept.roles.length === 0) {
89
- errors.push(`departments[${i}].roles must be a non-empty array`);
90
- } else if (dept.roles.length > MAX_ROLES_PER_DEPARTMENT) {
91
- errors.push(`departments[${i}].roles exceeds max of ${MAX_ROLES_PER_DEPARTMENT}`);
92
- }
93
- }
94
- }
95
- }
96
- return errors;
97
- }
98
-
99
- /**
100
- * Read and validate `<cwd>/.cx/profile.json`. Returns:
101
- * { status: 'absent' } if the file does not exist
102
- * { status: 'invalid', errors: [...] } if validation failed
103
- * { status: 'ok', profile } if the file is a valid custom profile
104
- */
105
- export function validateCustomProfileFile(cwd) {
106
- const p = path.join(cwd, '.cx', 'profile.json');
107
- if (!fs.existsSync(p)) return { status: 'absent' };
108
- let raw;
109
- try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); }
110
- catch (err) { return { status: 'invalid', errors: [`malformed JSON: ${err.message}`] }; }
111
- const errors = validateCustomProfile(raw, { cwd });
112
- if (errors.length > 0) return { status: 'invalid', errors };
113
- return { status: 'ok', profile: raw };
114
- }