@geraldmaron/construct 1.0.3 → 1.0.4

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 (75) hide show
  1. package/README.md +4 -4
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +23 -0
  19. package/db/schema/004_recommendations.sql +46 -0
  20. package/db/schema/005_strategy.sql +21 -0
  21. package/lib/auto-docs.mjs +1 -2
  22. package/lib/beads-automation.mjs +16 -7
  23. package/lib/cli-commands.mjs +8 -2
  24. package/lib/embed/conflict-detection.mjs +26 -9
  25. package/lib/embed/customer-profiles.mjs +37 -17
  26. package/lib/embed/daemon.mjs +10 -8
  27. package/lib/embed/recommendation-store.mjs +213 -14
  28. package/lib/embed/workspaces.mjs +53 -18
  29. package/lib/gates-audit.mjs +3 -3
  30. package/lib/health-check.mjs +1 -1
  31. package/lib/hooks/pre-compact.mjs +3 -0
  32. package/lib/hooks/read-tracker.mjs +10 -101
  33. package/lib/host-capabilities.mjs +90 -1
  34. package/lib/init-update.mjs +246 -131
  35. package/lib/intent-classifier.mjs +1 -0
  36. package/lib/knowledge/layout.mjs +10 -0
  37. package/lib/mcp/tools/telemetry.mjs +30 -78
  38. package/lib/model-router.mjs +61 -1
  39. package/lib/ollama-manager.mjs +1 -1
  40. package/lib/opencode-telemetry.mjs +4 -5
  41. package/lib/orchestration-policy.mjs +9 -0
  42. package/lib/parity.mjs +124 -21
  43. package/lib/prompt-composer.js +106 -29
  44. package/lib/read-tracker-store.mjs +149 -0
  45. package/lib/server/index.mjs +76 -0
  46. package/lib/server/telemetry-login.mjs +17 -25
  47. package/lib/service-manager.mjs +30 -22
  48. package/lib/services/local-postgres.mjs +15 -0
  49. package/lib/services/telemetry-backend.mjs +1 -2
  50. package/lib/setup.mjs +8 -43
  51. package/lib/status.mjs +51 -5
  52. package/lib/storage/backend.mjs +12 -2
  53. package/lib/strategy-store.mjs +371 -0
  54. package/lib/telemetry/backends/local.mjs +6 -4
  55. package/lib/telemetry/client.mjs +185 -0
  56. package/lib/telemetry/ingest.mjs +13 -5
  57. package/lib/telemetry/team-rollup.mjs +9 -2
  58. package/lib/worker/trace.mjs +17 -27
  59. package/package.json +5 -2
  60. package/rules/common/research.md +44 -12
  61. package/skills/docs/backlog-proposal-workflow.md +2 -2
  62. package/skills/docs/customer-profile-workflow.md +1 -1
  63. package/skills/docs/evidence-ingest-workflow.md +5 -5
  64. package/skills/docs/prfaq-workflow.md +1 -1
  65. package/skills/docs/product-intelligence-review.md +1 -1
  66. package/skills/docs/product-intelligence-workflow.md +3 -3
  67. package/skills/docs/product-signal-workflow.md +48 -18
  68. package/skills/docs/research-workflow.md +26 -14
  69. package/skills/docs/strategy-workflow.md +36 -0
  70. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  71. package/skills/roles/researcher.md +28 -15
  72. package/skills/routing.md +8 -1
  73. package/templates/docs/research-brief.md +63 -9
  74. package/templates/docs/strategy.md +36 -0
  75. package/templates/homebrew/construct.rb +6 -6
@@ -45,6 +45,61 @@ export const MODEL_TIER_BY_WORK_CATEGORY = {
45
45
  analysis: "standard",
46
46
  };
47
47
 
48
+ export const MODEL_OPERATING_PROFILES = Object.freeze({
49
+ balanced: {
50
+ id: 'balanced',
51
+ label: 'Balanced',
52
+ maxPromptTokens: 3000,
53
+ learnedPatternsTokens: 200,
54
+ taskPacketTokens: 150,
55
+ contextDigestTokens: 200,
56
+ hostConstraintsTokens: 75,
57
+ roleFlavorTokens: 600,
58
+ retrievalFirst: false,
59
+ preferCompressedRoleGuidance: false,
60
+ },
61
+ small: {
62
+ id: 'small',
63
+ label: 'Small-model',
64
+ maxPromptTokens: 1800,
65
+ learnedPatternsTokens: 120,
66
+ taskPacketTokens: 110,
67
+ contextDigestTokens: 120,
68
+ hostConstraintsTokens: 40,
69
+ roleFlavorTokens: 280,
70
+ retrievalFirst: true,
71
+ preferCompressedRoleGuidance: true,
72
+ },
73
+ });
74
+
75
+ function normalizeModelOperatingProfile(value) {
76
+ const normalized = String(value || '').trim().toLowerCase();
77
+ if (!normalized) return null;
78
+ if (normalized === 'default') return 'balanced';
79
+ return MODEL_OPERATING_PROFILES[normalized] ? normalized : null;
80
+ }
81
+
82
+ function inferSmallModelProfile(selectedModel) {
83
+ const model = String(selectedModel || '').toLowerCase();
84
+ if (!model) return false;
85
+ if (/^(ollama|local)\//.test(model) && /(?:[:/-])(3b|7b|8b|13b|14b|32b)\b/.test(model)) return true;
86
+ if (/^(anthropic|openrouter\/anthropic)\/.*haiku/.test(model)) return true;
87
+ if (/gpt-5\.1-mini|gemma-3|gemma-4|phi3:mini/.test(model)) return true;
88
+ return false;
89
+ }
90
+
91
+ export function resolveModelOperatingProfile({
92
+ envValues = {},
93
+ selectedModel = null,
94
+ } = {}) {
95
+ const explicit = normalizeModelOperatingProfile(
96
+ envValues.CONSTRUCT_MODEL_PROFILE ?? envValues.constructModelProfile
97
+ );
98
+ if (explicit) return MODEL_OPERATING_PROFILES[explicit];
99
+ if (inferSmallModelProfile(selectedModel)) return MODEL_OPERATING_PROFILES.small;
100
+ return MODEL_OPERATING_PROFILES.balanced;
101
+ }
102
+
48
103
  /**
49
104
  * Provider-family definitions. Each entry contains:
50
105
  * - `test`: RegExp that matches provider URLs.
@@ -229,7 +284,7 @@ const PROVIDER_ENV_MAP = {
229
284
  'github-copilot': ['GITHUB_TOKEN', 'GH_TOKEN'],
230
285
  'openai': ['OPENAI_API_KEY'],
231
286
  'openrouter-llama': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
232
- 'ollama': ['OLLAMA_BASE_URL'],
287
+ 'ollama': ['OLLAMA_BASE_URL', 'OLLAMA_HOST'],
233
288
  'local': ['LOCAL_LLM_BASE_URL'],
234
289
  };
235
290
 
@@ -630,6 +685,10 @@ export function resolveExecutionContractModelMetadata({
630
685
  const tiers = resolveTierAssignments(envValues, registryModels);
631
686
  const selectedTier = requestedTier ?? selectModelTierForWorkCategory(workCategory);
632
687
  const selected = selectedTier ? tiers[selectedTier] : null;
688
+ const profile = resolveModelOperatingProfile({
689
+ envValues,
690
+ selectedModel: selected?.model ?? null,
691
+ });
633
692
 
634
693
  return {
635
694
  version: "v1",
@@ -638,6 +697,7 @@ export function resolveExecutionContractModelMetadata({
638
697
  selectedTier: selectedTier ?? null,
639
698
  selectedModel: selected?.model ?? null,
640
699
  selectedModelSource: selected?.source ?? null,
700
+ profile,
641
701
  tiers,
642
702
  };
643
703
  }
@@ -10,7 +10,7 @@ import { execSync } from 'node:child_process';
10
10
  import { join } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
12
 
13
- const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
13
+ const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || process.env.OLLAMA_HOST || 'http://localhost:11434';
14
14
 
15
15
  /**
16
16
  * Check if Ollama is installed and running
@@ -5,7 +5,7 @@
5
5
  * trace/generation/span/event observations, and flushes on session.idle.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { createIngestClient } from "./telemetry/ingest.mjs";
8
+ import { createTelemetryClient } from "./telemetry/client.mjs";
9
9
 
10
10
  const SECRET_PATH_PATTERNS = [
11
11
  /(^|\/)\.env(\.|$)/i,
@@ -24,10 +24,9 @@ let cachedIngest = null;
24
24
 
25
25
  export function getIngestClient(env = process.env) {
26
26
  if (cachedIngest) return cachedIngest;
27
- cachedIngest = createIngestClient({
28
- baseUrl: env.CONSTRUCT_TELEMETRY_URL,
29
- publicKey: env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
30
- secretKey: env.CONSTRUCT_TELEMETRY_SECRET_KEY,
27
+ cachedIngest = createTelemetryClient({
28
+ env,
29
+ rootDir: env.CX_TOOLKIT_DIR,
31
30
  onError: (err) => {
32
31
  if (env.CONSTRUCT_TRACE_DEBUG === "1") {
33
32
  // eslint-disable-next-line no-console
@@ -324,6 +324,14 @@ export function classifyDataEngineerFlavor(request = '') {
324
324
  return null;
325
325
  }
326
326
 
327
+ export function classifyEngineerFlavor(request = '') {
328
+ const text = String(request).toLowerCase();
329
+ if (containsAny(text, ['llm', 'agent', 'prompt', 'eval', 'hallucination', 'rag', 'model behavior', 'model routing'])) return 'ai';
330
+ if (containsAny(text, ['vector', 'embedding', 'retrieval', 'warehouse', 'etl', 'elt', 'pipeline', 'streaming', 'data contract'])) return 'data';
331
+ if (containsAny(text, ['ci', 'build', 'deploy', 'docker', 'kubernetes', 'terraform', 'helm', 'release pipeline', 'platform tooling', 'developer experience'])) return 'platform';
332
+ return null;
333
+ }
334
+
327
335
  export function isDataAnalysisRequest(request = '') {
328
336
  return Boolean(classifyDataAnalystFlavor(request));
329
337
  }
@@ -406,6 +414,7 @@ export function isVisualDeliverableRequest(request = '') {
406
414
 
407
415
  export function classifyRoleFlavors(request = '') {
408
416
  return {
417
+ engineer: classifyEngineerFlavor(request),
409
418
  architect: classifyArchitectFlavor(request),
410
419
  productManager: isProductIntelligenceRequest(request) ? classifyProductManagerFlavor(request) : null,
411
420
  qa: classifyQaFlavor(request),
package/lib/parity.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * lib/parity.mjs — Cross-surface parity verifier.
3
3
  *
4
4
  * After `construct sync` writes adapters to multiple surfaces (Claude Code,
5
- * OpenCode, Codex), the module diffs each surface's actual state against the
5
+ * OpenCode, Codex, Copilot, VS Code, Cursor), the module diffs each surface's actual state against the
6
6
  * canonical `agents/registry.json`. Backs `construct doctor` for surfacing
7
7
  * silent divergence — for instance, an agent added to the registry that
8
8
  * never made it to OpenCode because of a sync regression.
@@ -14,9 +14,12 @@
14
14
  * the field are mirrored everywhere.
15
15
  *
16
16
  * Surfaces checked:
17
- * claude ~/.claude/agents/*.md
18
- * opencode ~/.config/opencode/opencode.json (agent table)
19
- * codex ~/.codex/agents/*.toml
17
+ * claude ~/.claude/agents/*.md
18
+ * opencode ~/.config/opencode/opencode.json (agent table)
19
+ * codex ~/.codex/agents/*.toml
20
+ * copilot ~/.github/prompts/*.prompt.md
21
+ * vscode VS Code user settings (github.copilot.mcpServers)
22
+ * cursor ~/.cursor/mcp.json
20
23
  */
21
24
 
22
25
  import fs from 'node:fs';
@@ -59,16 +62,15 @@ function diffSets(expected, actual) {
59
62
  return { missing, extra };
60
63
  }
61
64
 
62
- function checkClaude(registry, { homeDir = os.homedir() } = {}) {
63
- const dir = path.join(homeDir, '.claude', 'agents');
64
- if (!fs.existsSync(dir)) return { surface: 'claude', status: 'absent', dir };
65
- const expected = entriesForSurface(registry, 'claude');
65
+ function checkFileSurface({ surface, kind, dir, extension, expected }) {
66
+ if (!fs.existsSync(dir)) return { surface, kind, status: 'absent', dir };
66
67
  const actual = fs.readdirSync(dir)
67
- .filter((name) => name.endsWith('.md'))
68
- .map((name) => name.replace(/\.md$/, ''));
68
+ .filter((name) => name.endsWith(extension))
69
+ .map((name) => name.slice(0, -extension.length));
69
70
  const { missing, extra } = diffSets(expected, actual);
70
71
  return {
71
- surface: 'claude',
72
+ surface,
73
+ kind,
72
74
  status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
73
75
  dir,
74
76
  expectedCount: expected.length,
@@ -78,20 +80,56 @@ function checkClaude(registry, { homeDir = os.homedir() } = {}) {
78
80
  };
79
81
  }
80
82
 
83
+ function getVSCodeSettingsPaths(homeDir) {
84
+ const platform = os.platform();
85
+ if (platform === 'darwin') {
86
+ return [
87
+ path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
88
+ path.join(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
89
+ ];
90
+ }
91
+ if (platform === 'linux') {
92
+ return [
93
+ path.join(homeDir, '.config', 'Code', 'User', 'settings.json'),
94
+ path.join(homeDir, '.config', 'Code - Insiders', 'User', 'settings.json'),
95
+ ];
96
+ }
97
+ if (platform === 'win32') {
98
+ const appData = process.env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming');
99
+ return [
100
+ path.join(appData, 'Code', 'User', 'settings.json'),
101
+ path.join(appData, 'Code - Insiders', 'User', 'settings.json'),
102
+ ];
103
+ }
104
+ return [];
105
+ }
106
+
107
+ function checkClaude(registry, { homeDir = os.homedir() } = {}) {
108
+ const expected = entriesForSurface(registry, 'claude');
109
+ return checkFileSurface({
110
+ surface: 'claude',
111
+ kind: 'agents',
112
+ dir: path.join(homeDir, '.claude', 'agents'),
113
+ extension: '.md',
114
+ expected,
115
+ });
116
+ }
117
+
81
118
  function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
82
119
  const file = path.join(homeDir, '.config', 'opencode', 'opencode.json');
83
- if (!fs.existsSync(file)) return { surface: 'opencode', status: 'absent', file };
120
+ if (!fs.existsSync(file)) return { surface: 'opencode', kind: 'agents', status: 'absent', file };
84
121
  let config;
85
122
  try {
86
123
  config = JSON.parse(fs.readFileSync(file, 'utf8'));
87
124
  } catch (err) {
88
- return { surface: 'opencode', status: 'unreadable', file, error: err.message };
125
+ return { surface: 'opencode', kind: 'agents', status: 'unreadable', file, error: err.message };
89
126
  }
90
127
  const expected = entriesForSurface(registry, 'opencode');
91
128
  const actual = Object.keys(config.agent || config.agents || {});
92
129
  const { missing, extra } = diffSets(expected, actual);
93
130
  return {
94
131
  surface: 'opencode',
132
+ kind: 'agents',
95
133
  status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
96
134
  file,
97
135
  expectedCount: expected.length,
@@ -102,17 +140,79 @@ function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
102
140
  }
103
141
 
104
142
  function checkCodex(registry, { homeDir = os.homedir() } = {}) {
105
- const dir = path.join(homeDir, '.codex', 'agents');
106
- if (!fs.existsSync(dir)) return { surface: 'codex', status: 'absent', dir };
107
143
  const expected = entriesForSurface(registry, 'codex');
108
- const actual = fs.readdirSync(dir)
109
- .filter((name) => name.endsWith('.toml'))
110
- .map((name) => name.replace(/\.toml$/, ''));
144
+ return checkFileSurface({
145
+ surface: 'codex',
146
+ kind: 'agents',
147
+ dir: path.join(homeDir, '.codex', 'agents'),
148
+ extension: '.toml',
149
+ expected,
150
+ });
151
+ }
152
+
153
+ function checkCopilot(registry, { homeDir = os.homedir() } = {}) {
154
+ const prefix = registry.prefix || 'cx';
155
+ const entries = [
156
+ ...(registry.personas || []).map((p) => ({ ...p, isPersona: true })),
157
+ ...(registry.agents || []).map((a) => ({ ...a, isPersona: false })),
158
+ ];
159
+ const expected = entries
160
+ .filter((entry) => !entry.internal)
161
+ .filter((entry) => !Array.isArray(entry.platforms) || entry.platforms.includes('copilot'))
162
+ .map((entry) => adapterName(entry, prefix));
163
+ return checkFileSurface({
164
+ surface: 'copilot',
165
+ kind: 'prompts',
166
+ dir: path.join(homeDir, '.github', 'prompts'),
167
+ extension: '.prompt.md',
168
+ expected,
169
+ });
170
+ }
171
+
172
+ function checkVSCode(registry, { homeDir = os.homedir() } = {}) {
173
+ const paths = getVSCodeSettingsPaths(homeDir).filter((settingsPath) => fs.existsSync(settingsPath));
174
+ if (paths.length === 0) return { surface: 'vscode', kind: 'mcps', status: 'absent', paths };
175
+ const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
176
+ const actualSet = new Set();
177
+ for (const settingsPath of paths) {
178
+ try {
179
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
180
+ for (const id of Object.keys(settings['github.copilot.mcpServers'] ?? {})) actualSet.add(id);
181
+ } catch (err) {
182
+ return { surface: 'vscode', kind: 'mcps', status: 'unreadable', file: settingsPath, error: err.message };
183
+ }
184
+ }
185
+ const actual = [...actualSet].sort();
111
186
  const { missing, extra } = diffSets(expected, actual);
112
187
  return {
113
- surface: 'codex',
188
+ surface: 'vscode',
189
+ kind: 'mcps',
114
190
  status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
115
- dir,
191
+ paths,
192
+ expectedCount: expected.length,
193
+ actualCount: actual.length,
194
+ missing,
195
+ extra,
196
+ };
197
+ }
198
+
199
+ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
200
+ const file = path.join(homeDir, '.cursor', 'mcp.json');
201
+ if (!fs.existsSync(file)) return { surface: 'cursor', kind: 'mcps', status: 'absent', file };
202
+ let config;
203
+ try {
204
+ config = JSON.parse(fs.readFileSync(file, 'utf8'));
205
+ } catch (err) {
206
+ return { surface: 'cursor', kind: 'mcps', status: 'unreadable', file, error: err.message };
207
+ }
208
+ const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
209
+ const actual = Object.keys(config.mcpServers ?? {});
210
+ const { missing, extra } = diffSets(expected, actual);
211
+ return {
212
+ surface: 'cursor',
213
+ kind: 'mcps',
214
+ status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
215
+ file,
116
216
  expectedCount: expected.length,
117
217
  actualCount: actual.length,
118
218
  missing,
@@ -130,13 +230,16 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
130
230
  checkClaude(registry, { homeDir }),
131
231
  checkOpenCode(registry, { homeDir }),
132
232
  checkCodex(registry, { homeDir }),
233
+ checkCopilot(registry, { homeDir }),
234
+ checkVSCode(registry, { homeDir }),
235
+ checkCursor(registry, { homeDir }),
133
236
  ];
134
237
 
135
238
  const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent');
136
239
  const summary = surfaces.map((s) => {
137
240
  if (s.status === 'absent') return `${s.surface}: not installed`;
138
241
  if (s.status === 'unreadable') return `${s.surface}: unreadable (${s.error})`;
139
- if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount})`;
242
+ if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount} ${s.kind})`;
140
243
  const parts = [];
141
244
  if (s.missing.length) parts.push(`missing: ${s.missing.join(', ')}`);
142
245
  if (s.extra.length) parts.push(`extra: ${s.extra.join(', ')}`);
@@ -8,8 +8,12 @@
8
8
  * NEW contract (no backward compat):
9
9
  * { metadata, fragments, system, messages, staticEndIndex, totalTokens }
10
10
  *
11
- * Fragment order: core → role-flavor → task-context → learned-patterns → task-packet → context-digest → host-constraints
12
- * learned-patterns is inserted before the task packet so agents see what has been learned before reading the specific task.
11
+ * Fragment order: core → role-flavor → model-profile → task-context → learned-patterns → task-packet → context-digest → strategy → host-constraints
12
+ * strategy is injected after context-digest so agents have strategic grounding before learned-patterns affect framing.
13
+ *
14
+ * Workspace-type overlay auto-selection: when workspaceType is passed, the matching role overlay
15
+ * is selected automatically (e.g. workspaceType='platform' → product-manager.platform overlay)
16
+ * unless roleFlavors explicitly overrides it.
13
17
  */
14
18
  import crypto from 'node:crypto';
15
19
  import fs from 'node:fs';
@@ -17,29 +21,45 @@ import { homedir } from 'node:os';
17
21
  import path from 'node:path';
18
22
 
19
23
  import { buildContextDigest, readContextState } from './context-state.mjs';
20
- import { resolveExecutionContractModelMetadata, selectModelTierForWorkCategory } from './model-router.mjs';
24
+ import {
25
+ MODEL_OPERATING_PROFILES,
26
+ resolveExecutionContractModelMetadata,
27
+ resolveModelOperatingProfile,
28
+ selectModelTierForWorkCategory,
29
+ } from './model-router.mjs';
21
30
  import { searchObservations } from './observation-store.mjs';
22
31
  import { routeRequest } from './orchestration-policy.mjs';
23
32
  import { resolvePromptEntry, resolvePromptMetadata } from './prompt-metadata.mjs';
24
33
  import { readRoleFile } from './role-preload.mjs';
25
34
  import { estimateTokens, estimatePromptTokens, estimateTokensSync } from './token-engine.js';
35
+ import { cxDir } from './paths.mjs';
36
+ import { getStrategyDigestSync } from './strategy-store.mjs';
26
37
 
27
- // Token limits (replacing char limits)
28
- const LEARNED_PATTERNS_TOKEN_LIMIT = 200;
29
- const MAX_OBSERVATIONS = 3; // matched to token budget
30
- const MAX_CONTEXT_TOKENS = 3000; // total prompt token budget
38
+ const MAX_OBSERVATIONS = 3;
31
39
 
32
40
  // Priority tiers (1 = never drop, 5 = drop first)
33
41
  const PRIORITY = {
34
42
  'core': 1,
35
43
  'task-packet': 1,
36
44
  'role-flavor': 2,
45
+ 'model-profile': 2,
37
46
  'context-digest': 3,
47
+ 'strategy': 3,
38
48
  'learned-patterns': 4,
39
49
  'host-constraints': 5,
40
50
  };
41
51
 
52
+ // workspaceType → roleFlavors auto-selection when caller doesn't override
53
+ const WORKSPACE_FLAVOR_MAP = {
54
+ platform: { productManager: 'platform', architect: 'platform', engineer: 'platform' },
55
+ enterprise: { productManager: 'enterprise', architect: 'enterprise' },
56
+ 'ai-product': { productManager: 'ai-product', architect: 'ai-systems' },
57
+ growth: { productManager: 'growth' },
58
+ product: {},
59
+ };
60
+
42
61
  const AGENT_FLAVOR_MAP = {
62
+ engineer: { classifierKey: 'engineer', rolePrefix: 'engineer' },
43
63
  architect: { classifierKey: 'architect', rolePrefix: 'architect' },
44
64
  'product-manager': { classifierKey: 'productManager', rolePrefix: 'product-manager' },
45
65
  qa: { classifierKey: 'qa', rolePrefix: 'qa' },
@@ -48,6 +68,18 @@ const AGENT_FLAVOR_MAP = {
48
68
  'data-engineer': { classifierKey: 'dataEngineer', rolePrefix: 'data-engineer' },
49
69
  };
50
70
 
71
+ // Merge workspaceType-derived overlays with explicit roleFlavors. Explicit always wins.
72
+ function resolveRoleFlavors(roleFlavors, workspaceType, shortName) {
73
+ const workspaceDefaults = workspaceType ? (WORKSPACE_FLAVOR_MAP[workspaceType] ?? {}) : {};
74
+ if (!roleFlavors && !workspaceType) return null;
75
+ return { ...workspaceDefaults, ...(roleFlavors || {}) };
76
+ }
77
+
78
+ // Synchronous strategy digest for prompt injection — reads all scope files from the strategy directory.
79
+ function buildStrategyBlock({ tokenLimit = 400 } = {}) {
80
+ return getStrategyDigestSync();
81
+ }
82
+
51
83
  function compactTokens(text, tokenLimit = 300, { modelId = 'default' } = {}) {
52
84
  if (!text) return '';
53
85
  const normalized = String(text).trim();
@@ -65,7 +97,13 @@ function readPromptBody(promptFile, rootDir) {
65
97
  return fs.readFileSync(filePath, 'utf8').trim();
66
98
  }
67
99
 
68
- function buildLearnedPatternsBlock(agentName, { intent = null, workCategory = null, project = null, modelId = 'default' } = {}) {
100
+ function buildLearnedPatternsBlock(agentName, {
101
+ intent = null,
102
+ workCategory = null,
103
+ project = null,
104
+ modelId = 'default',
105
+ tokenLimit = MODEL_OPERATING_PROFILES.balanced.learnedPatternsTokens,
106
+ } = {}) {
69
107
  try {
70
108
  const rootDir = homedir();
71
109
  const query = [agentName, intent, workCategory].filter(Boolean).join(' ');
@@ -93,7 +131,7 @@ function buildLearnedPatternsBlock(agentName, { intent = null, workCategory = nu
93
131
  const prefix = obs.category === 'anti-pattern' ? '⚠ ' : obs.category === 'decision' ? '✓ ' : '• ';
94
132
  const line = `${prefix}${obs.summary}`;
95
133
  const lineTokens = estimateTokensSync(line + '\n', { modelId });
96
- if (tokens + lineTokens > LEARNED_PATTERNS_TOKEN_LIMIT) break;
134
+ if (tokens + lineTokens > tokenLimit) break;
97
135
  lines.push(line);
98
136
  tokens += lineTokens + 1;
99
137
  }
@@ -144,42 +182,68 @@ export function composePrompt(agentName, {
144
182
  intent = null,
145
183
  workCategory = null,
146
184
  roleFlavors = null,
185
+ workspaceType = null,
147
186
  project = null,
148
187
  injectLearnedPatterns = true,
149
188
  modelId = 'default',
189
+ executionContractModel = null,
150
190
  } = {}) {
151
191
  const entry = resolvePromptEntry(agentName, { rootDir, registry });
152
192
  if (!entry?.promptFile) return { metadata: {}, fragments: [], system: '', messages: [], staticEndIndex: -1, totalTokens: 0 };
153
193
 
154
194
  const metadata = resolvePromptMetadata(agentName, { rootDir, registry });
155
195
  const fragments = [];
196
+ const selectedProfile = resolveModelOperatingProfile({
197
+ envValues: executionContractModel?.profile ? { CONSTRUCT_MODEL_PROFILE: executionContractModel.profile.id } : {},
198
+ selectedModel: executionContractModel?.selectedModel ?? modelId,
199
+ });
156
200
 
157
- // 1. Core (priority 1 — never dropped)
158
201
  fragments.push({ type: 'core', priority: PRIORITY['core'], label: entry.name, content: readPromptBody(entry.promptFile, rootDir), tokenBudget: null });
159
202
 
160
- // 2. Role flavor overlay (priority 2)
161
203
  const shortName = String(agentName).replace(/^cx-/, '');
162
204
  const flavorMapping = AGENT_FLAVOR_MAP[shortName];
163
- if (flavorMapping && roleFlavors) {
164
- const flavor = roleFlavors[flavorMapping.classifierKey];
205
+
206
+ // When workspaceType is provided and roleFlavors doesn't already specify this agent's flavor,
207
+ // auto-select the matching overlay so agents get domain guidance without requiring explicit caller config.
208
+ const effectiveRoleFlavors = resolveRoleFlavors(roleFlavors, workspaceType, shortName);
209
+
210
+ if (flavorMapping && effectiveRoleFlavors) {
211
+ const flavor = effectiveRoleFlavors[flavorMapping.classifierKey];
165
212
  if (flavor) {
166
213
  const overlayBody = readRoleFile(rootDir, `${flavorMapping.rolePrefix}.${flavor}`, {
167
214
  source: 'prompt-composer',
168
215
  callerContext: agentName,
169
216
  });
170
217
  if (overlayBody) {
218
+ const overlayContent = selectedProfile.preferCompressedRoleGuidance
219
+ ? compactTokens(overlayBody, selectedProfile.roleFlavorTokens, { modelId })
220
+ : overlayBody;
171
221
  fragments.push({
172
222
  type: 'role-flavor',
173
223
  priority: PRIORITY['role-flavor'],
174
224
  label: `${flavorMapping.rolePrefix}.${flavor}`,
175
- content: `### ${flavor} domain guidance\n\n${overlayBody}`,
176
- tokenBudget: null,
225
+ content: `### ${flavor} domain guidance\n\n${overlayContent}`,
226
+ tokenBudget: selectedProfile.roleFlavorTokens,
177
227
  });
178
228
  }
179
229
  }
180
230
  }
181
231
 
182
- // 3. Task context (priority varies, part of static if present)
232
+ if (selectedProfile.retrievalFirst) {
233
+ fragments.push({
234
+ type: 'model-profile',
235
+ priority: PRIORITY['model-profile'],
236
+ label: `model-profile.${selectedProfile.id}`,
237
+ content: [
238
+ `## ${selectedProfile.label} operating mode`,
239
+ '',
240
+ 'Prefer retrieval-first execution: gather focused evidence before broad edits.',
241
+ 'Keep plans and summaries compact, stage work in verified steps, and avoid whole-file rewrites unless the task requires them.',
242
+ ].join('\n'),
243
+ tokenBudget: 90,
244
+ });
245
+ }
246
+
183
247
  if (intent || workCategory) {
184
248
  const text = `Intent: ${intent || 'unknown'}\nWork category: ${workCategory || 'unknown'}`;
185
249
  fragments.push({
@@ -191,10 +255,13 @@ export function composePrompt(agentName, {
191
255
  });
192
256
  }
193
257
 
194
- // 4. Learned patterns (priority 4)
195
258
  if (injectLearnedPatterns) {
196
259
  const { text: learnedBlock, tokens: learnedTokens } = buildLearnedPatternsBlock(agentName, {
197
- intent, workCategory, project, modelId,
260
+ intent,
261
+ workCategory,
262
+ project,
263
+ modelId,
264
+ tokenLimit: selectedProfile.learnedPatternsTokens,
198
265
  });
199
266
  if (learnedBlock) {
200
267
  fragments.push({
@@ -202,13 +269,12 @@ export function composePrompt(agentName, {
202
269
  priority: PRIORITY['learned-patterns'],
203
270
  label: 'observations',
204
271
  content: learnedBlock,
205
- tokenBudget: LEARNED_PATTERNS_TOKEN_LIMIT,
272
+ tokenBudget: selectedProfile.learnedPatternsTokens,
206
273
  estimatedTokens: learnedTokens,
207
274
  });
208
275
  }
209
276
  }
210
277
 
211
- // 5. Task packet (priority 1 — never dropped)
212
278
  if (task) {
213
279
  const taskBlock = [
214
280
  task.title ? `Task: ${task.title}` : null,
@@ -229,13 +295,12 @@ export function composePrompt(agentName, {
229
295
  type: 'task-packet',
230
296
  priority: PRIORITY['task-packet'],
231
297
  label: 'workflow-task',
232
- content: compactTokens(taskBlock, 150, { modelId }),
233
- tokenBudget: 150,
298
+ content: compactTokens(taskBlock, selectedProfile.taskPacketTokens, { modelId }),
299
+ tokenBudget: selectedProfile.taskPacketTokens,
234
300
  });
235
301
  }
236
302
  }
237
303
 
238
- // 6. Context digest (priority 3)
239
304
  const digest = buildContextDigest(contextState);
240
305
  if (digest) {
241
306
  const digestStr = JSON.stringify(digest);
@@ -243,24 +308,34 @@ export function composePrompt(agentName, {
243
308
  type: 'context-digest',
244
309
  priority: PRIORITY['context-digest'],
245
310
  label: 'context',
246
- content: compactTokens(digestStr, 200, { modelId }),
247
- tokenBudget: 200,
311
+ content: compactTokens(digestStr, selectedProfile.contextDigestTokens, { modelId }),
312
+ tokenBudget: selectedProfile.contextDigestTokens,
313
+ });
314
+ }
315
+
316
+ const strategyText = buildStrategyBlock({ modelId, tokenLimit: selectedProfile.strategyTokens ?? 400 });
317
+ if (strategyText) {
318
+ fragments.push({
319
+ type: 'strategy',
320
+ priority: PRIORITY['strategy'],
321
+ label: 'active-strategy',
322
+ content: strategyText,
323
+ tokenBudget: selectedProfile.strategyTokens ?? 400,
248
324
  });
249
325
  }
250
326
 
251
- // 7. Host constraints (priority 5 — dropped first)
252
327
  if (hostConstraints) {
253
328
  fragments.push({
254
329
  type: 'host-constraints',
255
330
  priority: PRIORITY['host-constraints'],
256
331
  label: 'host',
257
- content: compactTokens(JSON.stringify(hostConstraints), 75, { modelId }),
258
- tokenBudget: 75,
332
+ content: compactTokens(JSON.stringify(hostConstraints), selectedProfile.hostConstraintsTokens, { modelId }),
333
+ tokenBudget: selectedProfile.hostConstraintsTokens,
259
334
  });
260
335
  }
261
336
 
262
337
  // Priority-based pruning
263
- const pruned = pruneFragments(fragments, MAX_CONTEXT_TOKENS, modelId);
338
+ const pruned = pruneFragments(fragments, selectedProfile.maxPromptTokens, modelId);
264
339
  const staticEndIndex = findStaticEndIndex(pruned);
265
340
 
266
341
  // Assemble outputs
@@ -366,6 +441,7 @@ export function summarizePromptComposition(agentName, options = {}) {
366
441
  roleFlavors: options.roleFlavors || route?.roleFlavors || null,
367
442
  });
368
443
  const fragmentTypes = composed.fragments.map((fragment) => fragment.type);
444
+ const flavorFragment = composed.fragments.find((fragment) => fragment.type === 'role-flavor');
369
445
  const composedPromptHash = composed.system
370
446
  ? crypto.createHash('sha256').update(composed.system).digest('hex')
371
447
  : null;
@@ -393,6 +469,7 @@ export function summarizePromptComposition(agentName, options = {}) {
393
469
  routeDispatchPlan: route.dispatchPlan,
394
470
  ...(route.roleFlavors ? { routeRoleFlavors: route.roleFlavors } : {}),
395
471
  } : {}),
472
+ ...(flavorFragment ? { promptRoleFlavor: flavorFragment.label } : {}),
396
473
  promptHasRoleFlavor: fragmentTypes.includes('role-flavor'),
397
474
  promptHasLearnedPatterns: fragmentTypes.includes('learned-patterns'),
398
475
  executionContractModel,