@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.
- package/README.md +23 -7
- package/bin/construct +206 -228
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +31 -10
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +39 -163
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +72 -10
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +169 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +59 -252
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +104 -480
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -43
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +7 -6
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -11
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +427 -107
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +3 -12
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +28 -36
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +15 -15
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/server/static/index.html +0 -1
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/vector-store.mjs +0 -100
package/lib/strategy-store.mjs
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
259
|
-
|
|
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
|
-
*
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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
|
+
}
|