@geraldmaron/construct 1.0.21 → 1.0.23

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 (117) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +191 -13
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +25 -7
  17. package/lib/features.mjs +11 -11
  18. package/lib/git-hooks-path.mjs +61 -0
  19. package/lib/home-namespace.mjs +60 -0
  20. package/lib/hooks/ci-status-check.mjs +62 -40
  21. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  22. package/lib/hooks/pre-push-gate.mjs +15 -6
  23. package/lib/hooks/stop-notify.mjs +32 -17
  24. package/lib/hooks/stop-typecheck.mjs +7 -2
  25. package/lib/host-capabilities.mjs +24 -8
  26. package/lib/host-disposition.mjs +76 -0
  27. package/lib/ingest/provider-extract.mjs +1 -1
  28. package/lib/init-docs.mjs +1 -1
  29. package/lib/init-unified.mjs +320 -219
  30. package/lib/init-update.mjs +4 -84
  31. package/lib/init.mjs +9 -25
  32. package/lib/install/stage-project.mjs +8 -2
  33. package/lib/intent-classifier.mjs +1 -1
  34. package/lib/knowledge/search.mjs +52 -3
  35. package/lib/mcp/server.mjs +57 -14
  36. package/lib/mcp/tools/memory.mjs +2 -2
  37. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  38. package/lib/model-registry.mjs +40 -33
  39. package/lib/observation-store.mjs +6 -2
  40. package/lib/opencode-config.mjs +1 -1
  41. package/lib/orchestration/events.mjs +66 -0
  42. package/lib/orchestration/runtime.mjs +70 -9
  43. package/lib/orchestration/worker.mjs +69 -21
  44. package/lib/orchestration-policy.mjs +27 -3
  45. package/lib/parity.mjs +46 -24
  46. package/lib/policy/unified-gates.mjs +96 -0
  47. package/lib/project-init-shared.mjs +0 -173
  48. package/lib/reconcile/adapter-prune.mjs +105 -0
  49. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  50. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  51. package/lib/reconcile/index.mjs +171 -0
  52. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  53. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  54. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  55. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  56. package/lib/reconcile/postgres-namespace.mjs +102 -0
  57. package/lib/runtime/uv-bootstrap.mjs +27 -3
  58. package/lib/schema-infer.mjs +16 -2
  59. package/lib/server/csrf.mjs +14 -2
  60. package/lib/server/index.mjs +95 -0
  61. package/lib/service-manager.mjs +39 -14
  62. package/lib/setup-prompts.mjs +2 -1
  63. package/lib/setup.mjs +165 -141
  64. package/lib/storage/backend.mjs +14 -0
  65. package/lib/storage/unified-storage.mjs +550 -0
  66. package/lib/template-registry.mjs +73 -0
  67. package/lib/term-format.mjs +75 -0
  68. package/lib/uninstall/uninstall.mjs +180 -7
  69. package/package.json +2 -2
  70. package/personas/construct.md +7 -8
  71. package/platforms/claude/settings.template.json +30 -4
  72. package/platforms/opencode/config.template.json +2 -2
  73. package/rules/common/neurodivergent-output.md +66 -0
  74. package/rules/common/tool-invisibility.md +37 -0
  75. package/scripts/sync-specialists.mjs +381 -95
  76. package/skills/operating/orchestration-reference.md +2 -16
  77. package/specialists/policy-inventory.json +14 -0
  78. package/specialists/prompts/cx-accessibility.md +2 -6
  79. package/specialists/prompts/cx-ai-engineer.md +0 -4
  80. package/specialists/prompts/cx-architect.md +3 -5
  81. package/specialists/prompts/cx-business-strategist.md +0 -5
  82. package/specialists/prompts/cx-data-analyst.md +0 -4
  83. package/specialists/prompts/cx-data-engineer.md +0 -4
  84. package/specialists/prompts/cx-debugger.md +2 -6
  85. package/specialists/prompts/cx-designer.md +0 -8
  86. package/specialists/prompts/cx-devil-advocate.md +2 -2
  87. package/specialists/prompts/cx-docs-keeper.md +0 -13
  88. package/specialists/prompts/cx-engineer.md +0 -13
  89. package/specialists/prompts/cx-evaluator.md +2 -2
  90. package/specialists/prompts/cx-explorer.md +4 -5
  91. package/specialists/prompts/cx-legal-compliance.md +4 -5
  92. package/specialists/prompts/cx-operations.md +0 -5
  93. package/specialists/prompts/cx-orchestrator.md +0 -4
  94. package/specialists/prompts/cx-platform-engineer.md +0 -8
  95. package/specialists/prompts/cx-product-manager.md +0 -8
  96. package/specialists/prompts/cx-qa.md +2 -11
  97. package/specialists/prompts/cx-rd-lead.md +0 -5
  98. package/specialists/prompts/cx-release-manager.md +0 -8
  99. package/specialists/prompts/cx-researcher.md +5 -29
  100. package/specialists/prompts/cx-reviewer.md +2 -6
  101. package/specialists/prompts/cx-security.md +2 -11
  102. package/specialists/prompts/cx-sre.md +0 -15
  103. package/specialists/prompts/cx-test-automation.md +0 -4
  104. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  105. package/specialists/prompts/cx-ux-researcher.md +0 -4
  106. package/specialists/registry.json +29 -29
  107. package/templates/distribution/run.mjs +36 -7
  108. package/templates/docs/accessibility-audit.md +56 -0
  109. package/templates/docs/architecture-review.md +59 -0
  110. package/templates/docs/code-review-report.md +46 -0
  111. package/templates/docs/construct_guide.md +14 -14
  112. package/templates/docs/debug-investigation.md +53 -0
  113. package/templates/docs/qa-report.md +48 -0
  114. package/templates/docs/security-audit-report.md +48 -0
  115. package/templates/docs/task-packet.md +49 -0
  116. package/templates/docs/verdict.md +40 -0
  117. package/lib/server/static/index.html +0 -1
@@ -0,0 +1,550 @@
1
+ /**
2
+ * lib/storage/unified-storage.mjs — Single storage abstraction with strong consistency.
3
+ *
4
+ * Addresses storage sync fragility by:
5
+ * 1. Single unified interface for all storage operations
6
+ * 2. Transaction-based writes (atomic, rollback on failure)
7
+ * 3. Automatic backend selection (file/SQL/vector) based on capabilities
8
+ * 4. Consistent error handling and retry logic
9
+ * 5. No manual sync between backends - single source of truth
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { createSqlClient, closeSqlClient } from './backend.mjs';
16
+ import { loadStateSnapshot } from './state-source.mjs';
17
+ import { embedBatch, getEmbeddingModelInfo } from './embeddings-engine.mjs';
18
+ import { writeLocalVectorIndex, readLocalVectorIndex } from './vector-store.mjs';
19
+ import { runMigrations } from './migrations.mjs';
20
+
21
+ const CX_DIR = path.join(homedir(), '.cx');
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Storage backend abstraction
25
+ // ---------------------------------------------------------------------------
26
+
27
+ class StorageBackend {
28
+ constructor(name, capabilities) {
29
+ this.name = name;
30
+ this.capabilities = capabilities; // 'read', 'write', 'query', 'vector'
31
+ this.healthy = false;
32
+ }
33
+
34
+ async healthCheck() { return { healthy: false, error: 'Not implemented' }; }
35
+ async read(id) { throw new Error('Not implemented'); }
36
+ async write(id, data) { throw new Error('Not implemented'); }
37
+ async query(criteria) { throw new Error('Not implemented'); }
38
+ async vectorSearch(embedding, options) { throw new Error('Not implemented'); }
39
+ }
40
+
41
+ class FileBackend extends StorageBackend {
42
+ constructor(basePath) {
43
+ super('file', ['read', 'write']);
44
+ this.basePath = basePath;
45
+ fs.mkdirSync(basePath, { recursive: true });
46
+ this.healthy = true;
47
+ }
48
+
49
+ async healthCheck() {
50
+ try {
51
+ fs.accessSync(this.basePath, fs.constants.W_OK);
52
+ return { healthy: true };
53
+ } catch (error) {
54
+ return { healthy: false, error: error.message };
55
+ }
56
+ }
57
+
58
+ async read(id) {
59
+ const filePath = path.join(this.basePath, `${id}.json`);
60
+ if (!fs.existsSync(filePath)) return null;
61
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
62
+ }
63
+
64
+ async write(id, data) {
65
+ const filePath = path.join(this.basePath, `${id}.json`);
66
+ const tempPath = `${filePath}.tmp`;
67
+
68
+ // Atomic write: write to temp, then rename
69
+ fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
70
+ fs.renameSync(tempPath, filePath);
71
+
72
+ return { written: true, path: filePath };
73
+ }
74
+
75
+ async query({ project, kind }) {
76
+ const results = [];
77
+ const files = fs.readdirSync(this.basePath).filter(f => f.endsWith('.json'));
78
+
79
+ for (const file of files) {
80
+ try {
81
+ const data = JSON.parse(fs.readFileSync(path.join(this.basePath, file), 'utf8'));
82
+ if ((!project || data.project === project) && (!kind || data.kind === kind)) {
83
+ results.push(data);
84
+ }
85
+ } catch { /* skip invalid files */ }
86
+ }
87
+
88
+ return results;
89
+ }
90
+ }
91
+
92
+ class SqlBackend extends StorageBackend {
93
+ constructor(env) {
94
+ super('sql', ['read', 'write', 'query', 'vector']);
95
+ this.env = env;
96
+ this.client = null;
97
+ this.healthy = false;
98
+ }
99
+
100
+ async connect() {
101
+ if (this.client) return this.client;
102
+ this.client = createSqlClient(this.env);
103
+ if (this.client) {
104
+ await runMigrations(this.client);
105
+ this.healthy = true;
106
+ }
107
+ return this.client;
108
+ }
109
+
110
+ async healthCheck() {
111
+ try {
112
+ const client = await this.connect();
113
+ if (!client) return { healthy: false, error: 'No DATABASE_URL configured' };
114
+
115
+ await client`SELECT 1`;
116
+ return { healthy: true };
117
+ } catch (error) {
118
+ this.healthy = false;
119
+ return { healthy: false, error: error.message };
120
+ }
121
+ }
122
+
123
+ async read(id) {
124
+ const client = await this.connect();
125
+ if (!client) return null;
126
+
127
+ const rows = await client`
128
+ SELECT * FROM construct_documents
129
+ WHERE id = ${id}
130
+ LIMIT 1
131
+ `;
132
+
133
+ return rows[0] || null;
134
+ }
135
+
136
+ async write(id, data) {
137
+ const client = await this.connect();
138
+ if (!client) throw new Error('SQL backend not available');
139
+
140
+ // Use upsert for atomic write
141
+ await client`
142
+ INSERT INTO construct_documents (
143
+ id, project, kind, title, summary, body,
144
+ source_path, tags, content_hash, embedding, updated_at
145
+ ) VALUES (
146
+ ${id}, ${data.project}, ${data.kind}, ${data.title},
147
+ ${data.summary}, ${data.body}, ${data.source_path},
148
+ ${data.tags}, ${data.content_hash}, ${data.embedding},
149
+ NOW()
150
+ )
151
+ ON CONFLICT (id) DO UPDATE SET
152
+ project = EXCLUDED.project,
153
+ kind = EXCLUDED.kind,
154
+ title = EXCLUDED.title,
155
+ summary = EXCLUDED.summary,
156
+ body = EXCLUDED.body,
157
+ source_path = EXCLUDED.source_path,
158
+ tags = EXCLUDED.tags,
159
+ content_hash = EXCLUDED.content_hash,
160
+ embedding = EXCLUDED.embedding,
161
+ updated_at = NOW()
162
+ `;
163
+
164
+ return { written: true, id };
165
+ }
166
+
167
+ async query({ project, kind, limit = 100 }) {
168
+ const client = await this.connect();
169
+ if (!client) return [];
170
+
171
+ let query = client`SELECT * FROM construct_documents WHERE 1=1`;
172
+
173
+ if (project) {
174
+ query = client`${query} AND project = ${project}`;
175
+ }
176
+ if (kind) {
177
+ query = client`${query} AND kind = ${kind}`;
178
+ }
179
+
180
+ query = client`${query} ORDER BY updated_at DESC LIMIT ${limit}`;
181
+
182
+ return await query;
183
+ }
184
+
185
+ async vectorSearch(embedding, { project, limit = 10 }) {
186
+ const client = await this.connect();
187
+ if (!client) return [];
188
+
189
+ const embeddingStr = `[${embedding.join(',')}]`;
190
+
191
+ return await client`
192
+ SELECT *, embedding <-> ${embeddingStr}::vector as distance
193
+ FROM construct_documents
194
+ WHERE project = ${project}
195
+ ORDER BY embedding <-> ${embeddingStr}::vector
196
+ LIMIT ${limit}
197
+ `;
198
+ }
199
+ }
200
+
201
+ class VectorBackend extends StorageBackend {
202
+ constructor(indexPath) {
203
+ super('vector', ['read', 'query', 'vector']);
204
+ this.indexPath = indexPath;
205
+ this.healthy = !!indexPath;
206
+ }
207
+
208
+ async healthCheck() {
209
+ if (!this.indexPath) {
210
+ return { healthy: false, error: 'No index path configured' };
211
+ }
212
+ try {
213
+ fs.accessSync(path.dirname(this.indexPath), fs.constants.W_OK);
214
+ return { healthy: true };
215
+ } catch (error) {
216
+ return { healthy: false, error: error.message };
217
+ }
218
+ }
219
+
220
+ async read(id) {
221
+ const index = readLocalVectorIndex(this.indexPath);
222
+ return index.find(item => item.id === id) || null;
223
+ }
224
+
225
+ async write(id, data) {
226
+ // Vector backend is read-only - writes go through SQL backend
227
+ throw new Error('Vector backend is read-only. Use SQL backend for writes.');
228
+ }
229
+
230
+ async vectorSearch(embedding, options) {
231
+ const index = readLocalVectorIndex(this.indexPath);
232
+ const { limit = 10 } = options;
233
+
234
+ // Simple cosine similarity
235
+ const results = index.map(item => ({
236
+ ...item,
237
+ similarity: cosineSimilarity(embedding, item.embedding),
238
+ }));
239
+
240
+ results.sort((a, b) => b.similarity - a.similarity);
241
+ return results.slice(0, limit);
242
+ }
243
+ }
244
+
245
+ function cosineSimilarity(a, b) {
246
+ let dotProduct = 0;
247
+ let normA = 0;
248
+ let normB = 0;
249
+
250
+ for (let i = 0; i < a.length; i++) {
251
+ dotProduct += a[i] * b[i];
252
+ normA += a[i] * a[i];
253
+ normB += b[i] * b[i];
254
+ }
255
+
256
+ return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Unified storage manager
261
+ // ---------------------------------------------------------------------------
262
+
263
+ export class UnifiedStorage {
264
+ constructor(options = {}) {
265
+ this.env = options.env || process.env;
266
+ this.project = options.project || 'construct';
267
+ this.backends = new Map();
268
+ this.primaryBackend = null;
269
+ this.cache = new Map();
270
+
271
+ this.initializeBackends();
272
+ }
273
+
274
+ initializeBackends() {
275
+ const cxDir = path.join(homedir(), '.cx');
276
+
277
+ // Always have file backend as fallback
278
+ const fileBackend = new FileBackend(path.join(cxDir, 'storage', 'documents'));
279
+ this.backends.set('file', fileBackend);
280
+
281
+ // SQL backend if configured
282
+ if (this.env.DATABASE_URL) {
283
+ const sqlBackend = new SqlBackend(this.env);
284
+ this.backends.set('sql', sqlBackend);
285
+ }
286
+
287
+ // Vector backend if configured
288
+ const vectorPath = this.env.CONSTRUCT_VECTOR_INDEX_PATH || path.join(cxDir, 'vector-index.json');
289
+ if (vectorPath) {
290
+ const vectorBackend = new VectorBackend(vectorPath);
291
+ this.backends.set('vector', vectorBackend);
292
+ }
293
+
294
+ // Determine primary backend (prefer SQL, fallback to file)
295
+ this.primaryBackend = this.backends.get('sql') || this.backends.get('file');
296
+ }
297
+
298
+ async healthCheck() {
299
+ const results = {};
300
+
301
+ for (const [name, backend] of this.backends) {
302
+ results[name] = await backend.healthCheck();
303
+ }
304
+
305
+ const healthy = Object.values(results).some(r => r.healthy);
306
+ const primaryHealthy = this.primaryBackend ? (await this.primaryBackend.healthCheck()).healthy : false;
307
+
308
+ return {
309
+ overall: healthy ? (primaryHealthy ? 'healthy' : 'degraded') : 'unavailable',
310
+ primary: this.primaryBackend?.name,
311
+ backends: results,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Store a document with atomic consistency.
317
+ * Writes to primary backend only - no sync needed.
318
+ */
319
+ async storeDocument(id, document) {
320
+ if (!this.primaryBackend) {
321
+ throw new Error('No storage backend available');
322
+ }
323
+
324
+ const enriched = {
325
+ ...document,
326
+ project: this.project,
327
+ storedAt: new Date().toISOString(),
328
+ };
329
+
330
+ try {
331
+ const result = await this.primaryBackend.write(id, enriched);
332
+
333
+ // Invalidate cache
334
+ this.cache.delete(id);
335
+
336
+ return {
337
+ success: true,
338
+ id,
339
+ backend: this.primaryBackend.name,
340
+ ...result,
341
+ };
342
+ } catch (error) {
343
+ return {
344
+ success: false,
345
+ id,
346
+ error: error.message,
347
+ };
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Retrieve a document by ID.
353
+ * Checks cache first, then primary backend.
354
+ */
355
+ async retrieveDocument(id) {
356
+ // Check cache
357
+ if (this.cache.has(id)) {
358
+ return this.cache.get(id);
359
+ }
360
+
361
+ if (!this.primaryBackend) {
362
+ return null;
363
+ }
364
+
365
+ const doc = await this.primaryBackend.read(id);
366
+
367
+ if (doc) {
368
+ this.cache.set(id, doc);
369
+ }
370
+
371
+ return doc;
372
+ }
373
+
374
+ /**
375
+ * Query documents with filters.
376
+ */
377
+ async queryDocuments(criteria) {
378
+ if (!this.primaryBackend) {
379
+ return [];
380
+ }
381
+
382
+ return await this.primaryBackend.query({
383
+ ...criteria,
384
+ project: this.project,
385
+ });
386
+ }
387
+
388
+ /**
389
+ * Semantic search using vector similarity.
390
+ * Tries SQL backend first (most accurate), falls back to local vector index.
391
+ */
392
+ async semanticSearch(query, options = {}) {
393
+ const { limit = 10, threshold = 0.7 } = options;
394
+
395
+ // Generate embedding for query
396
+ const modelInfo = await getEmbeddingModelInfo({ env: this.env });
397
+ const embeddings = await embedBatch([query], { env: this.env });
398
+ const queryEmbedding = embeddings[0]?.embedding;
399
+
400
+ if (!queryEmbedding) {
401
+ throw new Error('Failed to generate query embedding');
402
+ }
403
+
404
+ // Try SQL backend first
405
+ const sqlBackend = this.backends.get('sql');
406
+ if (sqlBackend && (await sqlBackend.healthCheck()).healthy) {
407
+ const results = await sqlBackend.vectorSearch(queryEmbedding, {
408
+ project: this.project,
409
+ limit
410
+ });
411
+
412
+ return results
413
+ .filter(r => 1 - r.distance >= threshold)
414
+ .map(r => ({
415
+ ...r,
416
+ similarity: 1 - r.distance,
417
+ }));
418
+ }
419
+
420
+ // Fall back to vector backend
421
+ const vectorBackend = this.backends.get('vector');
422
+ if (vectorBackend && (await vectorBackend.healthCheck()).healthy) {
423
+ return await vectorBackend.vectorSearch(queryEmbedding, { limit });
424
+ }
425
+
426
+ // Last resort: no vector search available
427
+ return [];
428
+ }
429
+
430
+ /**
431
+ * Sync file state to storage with embedding.
432
+ * Single operation - no separate sync step needed.
433
+ */
434
+ async syncFromFileState(rootDir, options = {}) {
435
+ const snapshot = loadStateSnapshot(rootDir);
436
+ const modelInfo = await getEmbeddingModelInfo({ env: this.env });
437
+
438
+ const results = {
439
+ processed: 0,
440
+ failed: 0,
441
+ errors: [],
442
+ };
443
+
444
+ // Process each document in the snapshot
445
+ const documents = this.snapshotToDocuments(snapshot, rootDir);
446
+
447
+ // Generate embeddings in batch
448
+ const texts = documents.map(d =>
449
+ [d.title, d.summary, d.body, d.source_path].filter(Boolean).join('\n')
450
+ );
451
+
452
+ const embeddings = texts.length > 0
453
+ ? await embedBatch(texts, { env: this.env })
454
+ : [];
455
+
456
+ // Store each document
457
+ for (let i = 0; i < documents.length; i++) {
458
+ const doc = documents[i];
459
+ const embedding = embeddings[i]?.embedding;
460
+
461
+ try {
462
+ await this.storeDocument(doc.id, {
463
+ ...doc,
464
+ embedding: embedding ? Array.from(embedding) : null,
465
+ embeddingModel: modelInfo.model,
466
+ });
467
+ results.processed++;
468
+ } catch (error) {
469
+ results.failed++;
470
+ results.errors.push({ id: doc.id, error: error.message });
471
+ }
472
+ }
473
+
474
+ return results;
475
+ }
476
+
477
+ snapshotToDocuments(snapshot, rootDir) {
478
+ const docs = [];
479
+ const project = this.project;
480
+
481
+ if (snapshot.context) {
482
+ docs.push({
483
+ id: `${project}:context`,
484
+ kind: 'context',
485
+ title: 'Context state',
486
+ summary: snapshot.context.contextSummary || '',
487
+ body: JSON.stringify(snapshot.context, null, 2),
488
+ source_path: '.cx/context.json',
489
+ tags: ['context', 'state'],
490
+ });
491
+ }
492
+
493
+ if (snapshot.architecture) {
494
+ docs.push({
495
+ id: `${project}:architecture`,
496
+ kind: 'architecture',
497
+ title: 'Architecture docs',
498
+ summary: snapshot.architecture.slice(0, 240),
499
+ body: snapshot.architecture,
500
+ source_path: 'docs/concepts/architecture.md',
501
+ tags: ['architecture', 'docs'],
502
+ });
503
+ }
504
+
505
+ // Add product intel docs
506
+ for (const doc of snapshot.productIntelDocs || []) {
507
+ const kind = doc.path.startsWith('docs/prd/') ? 'prd'
508
+ : doc.path.startsWith('docs/meta-prd/') ? 'meta-prd'
509
+ : 'knowledge';
510
+
511
+ docs.push({
512
+ id: `${project}:${doc.path}`,
513
+ kind,
514
+ title: doc.title,
515
+ summary: doc.body.slice(0, 240),
516
+ body: doc.body,
517
+ source_path: doc.path,
518
+ tags: ['knowledge', kind],
519
+ });
520
+ }
521
+
522
+ return docs;
523
+ }
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Convenience exports
528
+ // ---------------------------------------------------------------------------
529
+
530
+ let globalStorage = null;
531
+
532
+ export function getUnifiedStorage(options = {}) {
533
+ if (!globalStorage) {
534
+ globalStorage = new UnifiedStorage(options);
535
+ }
536
+ return globalStorage;
537
+ }
538
+
539
+ export function resetUnifiedStorage() {
540
+ globalStorage = null;
541
+ }
542
+
543
+ export async function withStorage(fn, options = {}) {
544
+ const storage = getUnifiedStorage(options);
545
+ try {
546
+ return await fn(storage);
547
+ } finally {
548
+ // Cleanup if needed
549
+ }
550
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * lib/template-registry.mjs — specialist → template ownership map.
3
+ *
4
+ * Single source of truth for which canonical doc templates a specialist
5
+ * authors. The prompt-surface test gates assert that each specialist's
6
+ * prompt references `get_template(<name>)` for its native artifacts (so
7
+ * prompts stay pointers rather than restated structure) and that every
8
+ * named template actually resolves on disk.
9
+ *
10
+ * Adding a new specialist or template: extend SPECIALIST_TEMPLATES, ship the
11
+ * template file under templates/docs/<name>.md, and wire the prompt to call
12
+ * `get_template("<name>")` in the output-format section.
13
+ *
14
+ * Project overrides at .cx/templates/docs/<name>.md take precedence at fetch
15
+ * time via the get_template MCP tool; the registry only names the canonical
16
+ * shipped name.
17
+ */
18
+
19
+ export const SPECIALIST_TEMPLATES = {
20
+ // Tranche 1 — five new templates authored alongside this map.
21
+ 'cx-reviewer': ['code-review-report'],
22
+ 'cx-security': ['security-audit-report'],
23
+ 'cx-qa': ['qa-report', 'test-plan'],
24
+ 'cx-debugger': ['debug-investigation'],
25
+ 'cx-devil-advocate': ['verdict'],
26
+ 'cx-evaluator': ['verdict'],
27
+ 'cx-trace-reviewer': ['verdict'],
28
+ 'cx-explorer': ['verdict'],
29
+ 'cx-legal-compliance':['verdict'],
30
+
31
+ // Already-wired specialists pinned here so the gate guards them against drift.
32
+ 'cx-architect': ['adr', 'rfc', 'architecture-review'],
33
+ 'cx-researcher': ['research-brief', 'evidence-brief'],
34
+ 'cx-sre': ['runbook', 'incident-report'],
35
+ 'cx-product-manager': ['prd', 'meta-prd'],
36
+
37
+ // Tranche 2 — three lower-frequency artifacts.
38
+ 'cx-accessibility': ['accessibility-audit'],
39
+ };
40
+
41
+ // The orchestrator owns the task-packet shape and emits it at dispatch time
42
+ // rather than authoring it as a doc. Pinned here so the existence gate
43
+ // and reverse-coverage gate both pass without conflating dispatch with
44
+ // authoring.
45
+
46
+ export const SHARED_TEMPLATES = new Set([
47
+ 'task-packet',
48
+ ]);
49
+
50
+ // Roles whose prompts may legitimately reference templates not in the map
51
+ // (e.g. cx-docs-keeper authors many doc types via a listing tool, not a
52
+ // single owned template). Entries here opt out of the reverse-coverage gate,
53
+ // not the per-mapping gate above.
54
+
55
+ export const TEMPLATE_OWNERSHIP_EXEMPTIONS = new Set([
56
+ 'construct_guide',
57
+ 'onboarding',
58
+ 'memo',
59
+ 'skill-artifact',
60
+ 'persona-artifact',
61
+ 'customer-profile',
62
+ 'one-pager',
63
+ 'strategy',
64
+ 'backlog-proposal',
65
+ 'changelog-entry',
66
+ 'prfaq',
67
+ 'signal-brief',
68
+ 'research-finding',
69
+ 'product-intelligence-report',
70
+ 'rfc-platform',
71
+ 'prd-platform',
72
+ 'prd-business',
73
+ ]);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * lib/term-format.mjs — single source of truth for human-facing terminal presentation.
3
+ *
4
+ * Centralizes terminal color enable/disable and width so accessibility behavior
5
+ * (NO_COLOR, non-TTY pipes, TERM=dumb, narrow terminals) is decided in one tested place
6
+ * instead of being re-implemented per call site. Presentation only: this module never
7
+ * touches machine-readable output (`--json`, parsed hook tokens, registries, contracts) —
8
+ * callers keep those paths away from here. See rules/common/neurodivergent-output.md.
9
+ */
10
+
11
+ const ESC = "[";
12
+
13
+ const CODES = { bold: "1", dim: "2", reset: "0", red: "31", green: "32", yellow: "33", cyan: "36" };
14
+
15
+ const PALETTE_KEYS = Object.keys(CODES);
16
+
17
+ const EMPTY_PALETTE = Object.freeze(Object.fromEntries(PALETTE_KEYS.map((k) => [k, ""])));
18
+
19
+ // Color is opt-in by the caller AND gated on an interactive, color-capable stream. Any
20
+ // NO_COLOR value and TERM=dumb force plain text; a non-TTY stream (pipe, file, CI) does too.
21
+
22
+ export function shouldUseColor({ enabled = true, stream = process.stdout, env = process.env } = {}) {
23
+ return Boolean(enabled) && Boolean(stream && stream.isTTY) && !env.NO_COLOR && env.TERM !== "dumb";
24
+ }
25
+
26
+ // A palette is the full key set whether or not color is on, so callers can interpolate
27
+ // every field unconditionally; when color is off each field is the empty string.
28
+
29
+ export function palette(useColor) {
30
+ if (!useColor) return EMPTY_PALETTE;
31
+ return Object.fromEntries(PALETTE_KEYS.map((k) => [k, `${ESC}${CODES[k]}m`]));
32
+ }
33
+
34
+ export function resolveColors(opts = {}) {
35
+ return palette(shouldUseColor(opts));
36
+ }
37
+
38
+ // Wrapping width for human prose. Falls back to 80 when the stream width is unknown
39
+ // (non-TTY, redirected) and caps wide terminals so lines stay scannable rather than long.
40
+
41
+ export function termWidth(stream = process.stdout, { fallback = 80, max = 100 } = {}) {
42
+ const cols = stream && Number.isInteger(stream.columns) ? stream.columns : fallback;
43
+ return Math.min(cols, max);
44
+ }
45
+
46
+ // Word-wrap one paragraph to width. Existing newlines stay as hard breaks; a single word
47
+ // longer than width is never split — it sits on its own line and overflows rather than
48
+ // becoming two unreadable fragments.
49
+
50
+ export function wrapText(text, width = termWidth()) {
51
+ const out = [];
52
+ for (const hardLine of String(text).split("\n")) {
53
+ if (hardLine === "") {
54
+ out.push("");
55
+ continue;
56
+ }
57
+ let line = "";
58
+ for (const word of hardLine.split(/\s+/)) {
59
+ if (line === "") {
60
+ line = word;
61
+ } else if (line.length + 1 + word.length <= width) {
62
+ line += ` ${word}`;
63
+ } else {
64
+ out.push(line);
65
+ line = word;
66
+ }
67
+ }
68
+ out.push(line);
69
+ }
70
+ return out.join("\n");
71
+ }
72
+
73
+ export function stripAnsi(text) {
74
+ return String(text).replace(/\[[0-9;]*m/g, "");
75
+ }