@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  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 +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
@@ -6,23 +6,19 @@
6
6
  * Strategy documents are NEVER auto-updated from ingested documents — they require
7
7
  * an explicit writeStrategy() call from a privileged caller.
8
8
  *
9
- * In team/enterprise mode Postgres is the secondary store (best-effort). Each scope
10
- * is stored as a separate row whose content is prefixed with `scope:{name}\n` so we
11
- * can store and retrieve scopes without adding a new column to the existing schema.
9
+ * Construct now uses embedded LanceDB for vectors only; strategy remains
10
+ * filesystem-primary for simplicity and Git-backed collaboration.
12
11
  *
13
12
  * getStrategyDigest() / getStrategyDigestSync() return a compact block (≤ 500 tokens)
14
13
  * suitable for injection into agent prompts. getStrategyDigestSync() is the file-only
15
14
  * synchronous path required where await is not available (e.g. prompt assembly).
16
15
  */
17
16
 
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
19
18
  import { join, basename } from 'node:path';
20
19
  import { cxDir } from './paths.mjs';
21
- import { hasSqlStore } from './storage/sql-store.mjs';
22
- import { createSqlClient } from './storage/backend.mjs';
23
20
 
24
21
  const VALID_SCOPES = ['product', 'technical', 'gtm', 'platform'];
25
- const SCOPE_PREFIX = 'scope:';
26
22
 
27
23
  // ── Path helpers ──────────────────────────────────────────────────────────────
28
24
 
@@ -38,9 +34,6 @@ export function strategyFilePath(scope = 'product', env = process.env) {
38
34
 
39
35
  /**
40
36
  * Return scope names that have corresponding files in the strategy directory.
41
- *
42
- * @param {object} [env]
43
- * @returns {string[]}
44
37
  */
45
38
  export function listStrategyScopes(env = process.env) {
46
39
  const dir = strategyDir(env);
@@ -57,315 +50,71 @@ export function listStrategyScopes(env = process.env) {
57
50
  // ── Read helpers ──────────────────────────────────────────────────────────────
58
51
 
59
52
  /**
60
- * Read one strategy scope from Postgres (primary) or file (fallback).
61
- *
62
- * @param {string} [scope]
63
- * @param {object} [env]
64
- * @returns {Promise<{ content: string, version: number, updatedAt: string, source: 'postgres'|'file'|'none', scope: string }>}
53
+ * Read one strategy scope from file.
65
54
  */
66
55
  export async function readStrategy(scope = 'product', env = process.env) {
67
- if (hasSqlStore(env)) {
68
- const client = createSqlClient(env);
69
- try {
70
- const project = env.CX_PROJECT || 'default';
71
- const prefix = `${SCOPE_PREFIX}${scope}\n`;
72
-
73
- // Rows stored with the scope prefix — match on content prefix
74
- const rows = await client`
75
- select content, version, updated_at
76
- from construct_strategy
77
- where project = ${project}
78
- and content like ${prefix + '%'}
79
- order by version desc
80
- limit 1
81
- `;
82
-
83
- if (rows.length > 0) {
84
- const row = rows[0];
85
- return {
86
- content: row.content.startsWith(prefix) ? row.content.slice(prefix.length) : row.content,
87
- version: row.version,
88
- updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
89
- source: 'postgres',
90
- scope,
91
- };
92
- }
93
- } catch {
94
- // Postgres unavailable — fall through to file
95
- } finally {
96
- if (client) await client.end({ timeout: 5 }).catch(() => {});
97
- }
98
- }
99
-
100
56
  const filePath = strategyFilePath(scope, env);
101
- if (existsSync(filePath)) {
57
+ if (!existsSync(filePath)) {
58
+ return { content: '', version: 0, updatedAt: null, source: 'none', scope };
59
+ }
60
+ try {
102
61
  const content = readFileSync(filePath, 'utf8');
103
- return { content, version: 1, updatedAt: new Date().toISOString(), source: 'file', scope };
62
+ const stat = statSync(filePath);
63
+ return {
64
+ content,
65
+ version: 1,
66
+ updatedAt: stat.mtime.toISOString(),
67
+ source: 'file',
68
+ scope,
69
+ };
70
+ } catch {
71
+ return { content: '', version: 0, updatedAt: null, source: 'none', scope };
104
72
  }
105
-
106
- return { content: '', version: 0, updatedAt: new Date().toISOString(), source: 'none', scope };
107
73
  }
108
74
 
109
75
  /**
110
- * Read all present strategy scopes.
111
- *
112
- * When Postgres is available it reads all rows for the project and parses scope
113
- * from the prefix. Files are used as the fallback or supplement for scopes that
114
- * have no Postgres row.
115
- *
116
- * @param {object} [env]
117
- * @returns {Promise<Map<string, { content: string, version: number, updatedAt: string, source: string }>>}
76
+ * Read all strategy scopes into a Map.
118
77
  */
119
78
  export async function readAllStrategies(env = process.env) {
120
79
  const result = new Map();
121
-
122
- if (hasSqlStore(env)) {
123
- const client = createSqlClient(env);
124
- try {
125
- const project = env.CX_PROJECT || 'default';
126
-
127
- // Fetch all rows — latest version per scope
128
- const rows = await client`
129
- select distinct on (
130
- substring(content from 1 for position(E'\n' in content))
131
- ) content, version, updated_at
132
- from construct_strategy
133
- where project = ${project}
134
- and content like ${SCOPE_PREFIX + '%'}
135
- order by
136
- substring(content from 1 for position(E'\n' in content)),
137
- version desc
138
- `;
139
-
140
- for (const row of rows) {
141
- const firstNewline = row.content.indexOf('\n');
142
- if (firstNewline === -1) continue;
143
- const header = row.content.slice(0, firstNewline);
144
- if (!header.startsWith(SCOPE_PREFIX)) continue;
145
- const rowScope = header.slice(SCOPE_PREFIX.length);
146
- const content = row.content.slice(firstNewline + 1);
147
- result.set(rowScope, {
148
- content,
149
- version: row.version,
150
- updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
151
- source: 'postgres',
152
- });
153
- }
154
- } catch {
155
- // Fall through to file-based reads
156
- } finally {
157
- if (client) await client.end({ timeout: 5 }).catch(() => {});
158
- }
159
- }
160
-
161
- // Supplement / replace with file-based reads for any scope present on disk
162
- const fileScopes = listStrategyScopes(env);
163
- for (const fileScope of fileScopes) {
164
- if (result.has(fileScope)) continue;
165
- const filePath = strategyFilePath(fileScope, env);
166
- try {
167
- const content = readFileSync(filePath, 'utf8');
168
- result.set(fileScope, {
169
- content,
170
- version: 1,
171
- updatedAt: new Date().toISOString(),
172
- source: 'file',
173
- });
174
- } catch {
175
- // Skip unreadable files
176
- }
80
+ const scopes = listStrategyScopes(env);
81
+ for (const scope of scopes) {
82
+ const data = await readStrategy(scope, env);
83
+ if (data.source !== 'none') result.set(scope, data);
177
84
  }
178
-
179
85
  return result;
180
86
  }
181
87
 
182
- // ── Digest helpers ────────────────────────────────────────────────────────────
183
-
184
- /**
185
- * Parse markdown into sections split on ## headers.
186
- *
187
- * @param {string} content
188
- * @returns {Array<{ header: string, body: string }>}
189
- */
190
- function parseSections(content) {
191
- const lines = content.split('\n');
192
- const sections = [];
193
- let current = null;
194
-
195
- for (const line of lines) {
196
- if (line.startsWith('## ')) {
197
- if (current) sections.push(current);
198
- current = { header: line.slice(3).trim(), body: '' };
199
- } else if (current) {
200
- current.body += line + '\n';
201
- }
202
- }
203
- if (current) sections.push(current);
204
- return sections;
205
- }
206
-
207
- /**
208
- * Truncate a body to the first N sentences.
209
- *
210
- * @param {string} body
211
- * @param {number} maxSentences
212
- * @returns {string}
213
- */
214
- function firstSentences(body, maxSentences = 2) {
215
- const trimmed = body.trim();
216
- if (!trimmed) return '';
217
- const sentences = trimmed.match(/[^.!?\n]+[.!?\n]+/g) || [trimmed];
218
- return sentences.slice(0, maxSentences).join(' ').trim();
219
- }
220
-
221
- /**
222
- * Build a compact markdown block from a single scope's content.
223
- *
224
- * @param {string} scopeName
225
- * @param {string} content
226
- * @param {number} charBudget — remaining character budget; mutated by reference via returned delta
227
- * @returns {{ text: string, charsUsed: number }}
228
- */
229
- function buildScopeBlock(scopeName, content, charBudget) {
230
- const sections = parseSections(content);
231
- if (!sections.length) return { text: '', charsUsed: 0 };
232
-
233
- const lines = [`### ${scopeName}`];
234
- let used = scopeName.length + 5;
235
-
236
- for (const { header, body } of sections) {
237
- if (used >= charBudget) break;
238
- const excerpt = firstSentences(body, 2);
239
- const line = excerpt ? `**${header}:** ${excerpt}` : `**${header}**`;
240
- lines.push(line);
241
- used += line.length + 1;
242
- }
243
-
244
- const text = lines.join('\n');
245
- return { text, charsUsed: used };
246
- }
247
-
248
88
  /**
249
- * Return a compact strategy digest (≤ 500 tokens) for agent prompt injection.
250
- *
251
- * Reads all present scopes asynchronously. Returns empty string when no scopes exist.
252
- *
253
- * @param {object} [env]
254
- * @returns {Promise<string>}
89
+ * Synchronous digest for prompt assembly.
255
90
  */
256
- export async function getStrategyDigest(env = process.env) {
91
+ export function getStrategyDigestSync(scope = 'product', env = process.env) {
92
+ const filePath = strategyFilePath(scope, env);
93
+ if (!existsSync(filePath)) return '';
257
94
  try {
258
- const all = await readAllStrategies(env);
259
- if (!all.size) return '';
260
-
261
- const TOKEN_LIMIT = 500;
262
- let charBudget = TOKEN_LIMIT * 4;
263
- const parts = ['## Active strategy'];
264
-
265
- for (const [scopeName, { content }] of all) {
266
- if (!content) continue;
267
- const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
268
- if (text) {
269
- parts.push(text);
270
- charBudget -= charsUsed;
271
- }
272
- if (charBudget <= 0) break;
273
- }
274
-
275
- return parts.length > 1 ? parts.join('\n') : '';
95
+ const content = readFileSync(filePath, 'utf8');
96
+ return content.trim().slice(0, 2000); // approx 500 tokens
276
97
  } catch {
277
98
  return '';
278
99
  }
279
100
  }
280
101
 
281
102
  /**
282
- * Synchronous file-only strategy digest for prompt-composer.js.
283
- *
284
- * Reads all .md files present in the strategy directory directly. Never reads
285
- * Postgres. Returns empty string when the directory is absent or empty.
286
- *
287
- * @param {object} [env]
288
- * @returns {string}
103
+ * Async digest for general use.
289
104
  */
290
- export function getStrategyDigestSync(env = process.env) {
291
- try {
292
- const dir = strategyDir(env);
293
- if (!existsSync(dir)) return '';
294
-
295
- const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
296
- if (!files.length) return '';
297
-
298
- const TOKEN_LIMIT = 500;
299
- let charBudget = TOKEN_LIMIT * 4;
300
- const parts = ['## Active strategy'];
301
-
302
- for (const file of files) {
303
- if (charBudget <= 0) break;
304
- const scopeName = basename(file, '.md');
305
- let content;
306
- try {
307
- content = readFileSync(join(dir, file), 'utf8');
308
- } catch {
309
- continue;
310
- }
311
- if (!content) continue;
312
- const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
313
- if (text) {
314
- parts.push(text);
315
- charBudget -= charsUsed;
316
- }
317
- }
318
-
319
- return parts.length > 1 ? parts.join('\n') : '';
320
- } catch {
321
- return '';
322
- }
105
+ export async function getStrategyDigest(scope = 'product', env = process.env) {
106
+ const data = await readStrategy(scope, env);
107
+ return data.content.trim().slice(0, 2000);
323
108
  }
324
109
 
325
- // ── Write ─────────────────────────────────────────────────────────────────────
110
+ // ── Write helpers ─────────────────────────────────────────────────────────────
326
111
 
327
112
  /**
328
- * Write a strategy scope to file (always) and Postgres (best-effort if available).
329
- *
330
- * The scope parameter defaults to 'product'. When writing to Postgres the content
331
- * is prefixed with `scope:{name}\n` to allow per-scope storage without a schema change.
332
- *
333
- * @param {string} content
334
- * @param {string} [scope]
335
- * @param {object} [opts]
336
- * @param {string} [opts.updatedBy]
337
- * @param {object} [opts.env]
113
+ * Write a strategy scope to file.
338
114
  */
339
115
  export async function writeStrategy(content, scope = 'product', { updatedBy, env = process.env } = {}) {
340
116
  const filePath = strategyFilePath(scope, env);
341
117
  const dir = strategyDir(env);
342
118
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
343
119
  writeFileSync(filePath, content, 'utf8');
344
-
345
- if (!hasSqlStore(env)) return;
346
-
347
- const client = createSqlClient(env);
348
- try {
349
- const project = env.CX_PROJECT || 'default';
350
- const scopedContent = `${SCOPE_PREFIX}${scope}\n${content}`;
351
- const prefix = `${SCOPE_PREFIX}${scope}\n`;
352
-
353
- const existing = await client`
354
- select version from construct_strategy
355
- where project = ${project}
356
- and content like ${prefix + '%'}
357
- order by version desc
358
- limit 1
359
- `;
360
- const nextVersion = existing.length > 0 ? existing[0].version + 1 : 1;
361
-
362
- await client`
363
- insert into construct_strategy (project, content, version, updated_by)
364
- values (${project}, ${scopedContent}, ${nextVersion}, ${updatedBy ?? null})
365
- `;
366
- } catch {
367
- // Best-effort Postgres write — file write already succeeded
368
- } finally {
369
- if (client) await client.end({ timeout: 5 }).catch(() => {});
370
- }
371
120
  }
@@ -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
+ }