@geraldmaron/construct 1.0.2 → 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 (97) hide show
  1. package/.env.example +1 -1
  2. package/README.md +4 -4
  3. package/agents/prompts/cx-ai-engineer.md +6 -26
  4. package/agents/prompts/cx-architect.md +1 -0
  5. package/agents/prompts/cx-business-strategist.md +2 -0
  6. package/agents/prompts/cx-data-analyst.md +6 -26
  7. package/agents/prompts/cx-docs-keeper.md +1 -31
  8. package/agents/prompts/cx-explorer.md +1 -0
  9. package/agents/prompts/cx-orchestrator.md +40 -112
  10. package/agents/prompts/cx-platform-engineer.md +2 -22
  11. package/agents/prompts/cx-product-manager.md +2 -1
  12. package/agents/prompts/cx-qa.md +0 -20
  13. package/agents/prompts/cx-rd-lead.md +2 -0
  14. package/agents/prompts/cx-researcher.md +77 -31
  15. package/agents/prompts/cx-security.md +11 -49
  16. package/agents/prompts/cx-sre.md +9 -43
  17. package/agents/prompts/cx-ux-researcher.md +1 -0
  18. package/agents/role-manifests.json +4 -4
  19. package/bin/construct +72 -11
  20. package/bin/construct-postinstall.mjs +1 -1
  21. package/db/schema/004_recommendations.sql +46 -0
  22. package/db/schema/005_strategy.sql +21 -0
  23. package/lib/auto-docs.mjs +1 -2
  24. package/lib/beads-automation.mjs +16 -7
  25. package/lib/bootstrap/resources.mjs +2 -2
  26. package/lib/cli-commands.mjs +8 -2
  27. package/lib/document-ingest.mjs +6 -5
  28. package/lib/embed/cli.mjs +16 -3
  29. package/lib/embed/conflict-detection.mjs +26 -9
  30. package/lib/embed/customer-profiles.mjs +38 -18
  31. package/lib/embed/daemon.mjs +59 -50
  32. package/lib/embed/inbox.mjs +30 -0
  33. package/lib/embed/recommendation-store.mjs +214 -15
  34. package/lib/embed/workspaces.mjs +53 -18
  35. package/lib/evaluator-optimizer.mjs +0 -2
  36. package/lib/features.mjs +11 -0
  37. package/lib/gates-audit.mjs +3 -3
  38. package/lib/health-check.mjs +3 -5
  39. package/lib/hooks/pre-compact.mjs +3 -0
  40. package/lib/hooks/read-tracker.mjs +10 -101
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +119 -3
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/install/first-invocation.mjs +4 -4
  45. package/lib/intake/queue.mjs +1 -1
  46. package/lib/integrations/intake-integrations.mjs +4 -5
  47. package/lib/intent-classifier.mjs +1 -0
  48. package/lib/knowledge/layout.mjs +10 -0
  49. package/lib/knowledge/rag.mjs +16 -0
  50. package/lib/mcp/tools/telemetry.mjs +30 -78
  51. package/lib/model-cheapest-provider.mjs +231 -0
  52. package/lib/model-router.mjs +68 -9
  53. package/lib/ollama-manager.mjs +1 -1
  54. package/lib/opencode-telemetry.mjs +4 -5
  55. package/lib/orchestration-policy.mjs +9 -0
  56. package/lib/parity.mjs +124 -21
  57. package/lib/project-profile.mjs +1 -1
  58. package/lib/prompt-composer.js +106 -29
  59. package/lib/read-tracker-store.mjs +149 -0
  60. package/lib/roles/catalog.mjs +133 -0
  61. package/lib/roles/preference.mjs +74 -0
  62. package/lib/server/index.mjs +109 -47
  63. package/lib/server/insights.mjs +1 -1
  64. package/lib/server/telemetry-login.mjs +17 -25
  65. package/lib/service-manager.mjs +32 -24
  66. package/lib/services/local-postgres.mjs +15 -0
  67. package/lib/services/telemetry-backend.mjs +2 -3
  68. package/lib/setup-prompts.mjs +2 -2
  69. package/lib/setup.mjs +55 -46
  70. package/lib/status.mjs +56 -8
  71. package/lib/storage/backend.mjs +12 -2
  72. package/lib/storage/postgres-backup.mjs +1 -1
  73. package/lib/strategy-store.mjs +371 -0
  74. package/lib/telemetry/backends/local.mjs +6 -4
  75. package/lib/telemetry/client.mjs +185 -0
  76. package/lib/telemetry/ingest.mjs +13 -5
  77. package/lib/telemetry/team-rollup.mjs +9 -2
  78. package/lib/uninstall/uninstall.mjs +1 -1
  79. package/lib/worker/trace.mjs +17 -27
  80. package/package.json +5 -2
  81. package/rules/common/research.md +44 -12
  82. package/skills/docs/backlog-proposal-workflow.md +2 -2
  83. package/skills/docs/customer-profile-workflow.md +1 -1
  84. package/skills/docs/evidence-ingest-workflow.md +5 -5
  85. package/skills/docs/prfaq-workflow.md +1 -1
  86. package/skills/docs/product-intelligence-review.md +1 -1
  87. package/skills/docs/product-intelligence-workflow.md +3 -3
  88. package/skills/docs/product-signal-workflow.md +48 -18
  89. package/skills/docs/research-workflow.md +26 -14
  90. package/skills/docs/strategy-workflow.md +36 -0
  91. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  92. package/skills/roles/researcher.md +28 -15
  93. package/skills/routing.md +8 -1
  94. package/templates/docs/construct_guide.md +2 -2
  95. package/templates/docs/research-brief.md +63 -9
  96. package/templates/docs/strategy.md +36 -0
  97. package/templates/homebrew/construct.rb +7 -7
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,7 +8,7 @@
8
8
  *
9
9
  * The profile is written to `<cwd>/.cx/project-profile.json` and consumed by:
10
10
  * - `construct skills scope` — reports relevant vs irrelevant skills
11
- * - `construct setup` — runs detection on bootstrap
11
+ * - `construct init` — runs detection on bootstrap
12
12
  * - future per-host filter generators (.claude/settings.json filter,
13
13
  * .opencode/construct-skills.json sidecars, etc.)
14
14
  */
@@ -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,
@@ -0,0 +1,149 @@
1
+ /**
2
+ * lib/hooks/read-tracker-store.mjs — batched persistence for read-efficiency tracking.
3
+ *
4
+ * The Read hook runs in a fresh process for every file read. Persisting the
5
+ * full session-efficiency store on every invocation is correct but noisy and
6
+ * expensive. This module appends compact JSONL deltas per read, then folds
7
+ * them into the durable session summary when the delta log is flushed.
8
+ */
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ const SESSION_IDLE_RESET_MS = 2 * 60 * 60 * 1000;
14
+ const REPEATED_READ_WARNING_THRESHOLD = 5;
15
+ const REPEATED_READ_TIER2_THRESHOLD = 8;
16
+ const LARGE_READ_WARNING_THRESHOLD = 3;
17
+ const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
18
+ const FILES_LRU_CAP = 200;
19
+ const EFFICIENCY_LARGE_READ_LIMIT = 400;
20
+
21
+ export function readTrackerPaths(env = process.env) {
22
+ const home = env.CX_HOME_OVERRIDE || env.HOME || homedir();
23
+ const cxDir = join(home, '.cx');
24
+ return {
25
+ cxDir,
26
+ efficiencyStore: join(cxDir, 'session-efficiency.json'),
27
+ deltaLog: join(cxDir, 'session-efficiency.reads.jsonl'),
28
+ warnFlags: join(cxDir, 'warn-flags.txt'),
29
+ };
30
+ }
31
+
32
+ export function freshEfficiencyStats(nowIso) {
33
+ return {
34
+ sessionStartedAt: nowIso,
35
+ lastUpdatedAt: nowIso,
36
+ readCount: 0,
37
+ uniqueFileCount: 0,
38
+ repeatedReadCount: 0,
39
+ largeReadCount: 0,
40
+ totalBytesRead: 0,
41
+ warnings: {},
42
+ files: {},
43
+ };
44
+ }
45
+
46
+ export function loadEfficiencyStats(nowIso, env = process.env) {
47
+ const { efficiencyStore } = readTrackerPaths(env);
48
+ const fresh = freshEfficiencyStats(nowIso);
49
+ try {
50
+ const existing = JSON.parse(readFileSync(efficiencyStore, 'utf8'));
51
+ const lastUpdated = new Date(existing.lastUpdatedAt || 0).getTime();
52
+ if (!lastUpdated || Date.now() - lastUpdated > SESSION_IDLE_RESET_MS) return fresh;
53
+ return { ...fresh, ...existing, warnings: existing.warnings || {}, files: existing.files || {} };
54
+ } catch {
55
+ return fresh;
56
+ }
57
+ }
58
+
59
+ function appendWarning(message, env = process.env) {
60
+ try {
61
+ const { warnFlags } = readTrackerPaths(env);
62
+ appendFileSync(warnFlags, `${message}\n`);
63
+ } catch { /* best effort */ }
64
+ }
65
+
66
+ function topRepeatedPath(files) {
67
+ return Object.entries(files || {})
68
+ .map(([filePath, value]) => ({ filePath, count: Number(value?.count || 0) }))
69
+ .filter((entry) => entry.count > 1)
70
+ .sort((a, b) => b.count - a.count || a.filePath.localeCompare(b.filePath))[0];
71
+ }
72
+
73
+ export function applyReadDelta(stats, delta, env = process.env) {
74
+ const existingFile = stats.files[delta.path];
75
+ const isLargeRead = Number(delta.limit || 0) > EFFICIENCY_LARGE_READ_LIMIT;
76
+
77
+ stats.readCount += 1;
78
+ stats.totalBytesRead += Number(delta.size || 0);
79
+ if (isLargeRead) stats.largeReadCount += 1;
80
+ if (existingFile) stats.repeatedReadCount += 1;
81
+ else stats.uniqueFileCount += 1;
82
+
83
+ stats.files[delta.path] = {
84
+ count: Number(existingFile?.count || 0) + 1,
85
+ size: Number(delta.size || 0),
86
+ lastReadAt: delta.ts,
87
+ lastRequestedLimit: Number(delta.limit || 0),
88
+ };
89
+
90
+ const fileEntries = Object.entries(stats.files);
91
+ if (fileEntries.length > FILES_LRU_CAP) {
92
+ fileEntries.sort((a, b) => (a[1]?.lastReadAt || '').localeCompare(b[1]?.lastReadAt || ''));
93
+ const drop = fileEntries.length - FILES_LRU_CAP;
94
+ for (let i = 0; i < drop; i++) delete stats.files[fileEntries[i][0]];
95
+ }
96
+
97
+ if (stats.repeatedReadCount >= REPEATED_READ_WARNING_THRESHOLD && !stats.warnings.repeatedReads) {
98
+ const top = topRepeatedPath(stats.files);
99
+ const topNote = top ? ` Top repeat: ${top.filePath} (${top.count}x).` : '';
100
+ appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session.${topNote} Use rg or construct distill before re-reading more files.`, env);
101
+ stats.warnings.repeatedReads = delta.ts;
102
+ if (stats.repeatedReadCount >= REPEATED_READ_TIER2_THRESHOLD && !stats.warnings.repeatedReadsTier2) {
103
+ appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session — consider using construct distill or rg before re-reading entire files.`, env);
104
+ stats.warnings.repeatedReadsTier2 = delta.ts;
105
+ }
106
+ }
107
+
108
+ if (stats.largeReadCount >= LARGE_READ_WARNING_THRESHOLD && !stats.warnings.largeReads) {
109
+ appendWarning(`Efficiency: ${stats.largeReadCount} large reads this session — prefer rg/glob plus targeted reads under 400 lines.`, env);
110
+ stats.warnings.largeReads = delta.ts;
111
+ }
112
+
113
+ if (stats.totalBytesRead >= TOTAL_BYTES_WARNING_THRESHOLD && !stats.warnings.totalBytes) {
114
+ appendWarning(`Efficiency: ${Math.round(stats.totalBytesRead / 1024)} KB read this session — consider distill/query-focused retrieval or compact context before continuing.`, env);
115
+ stats.warnings.totalBytes = delta.ts;
116
+ }
117
+
118
+ stats.lastUpdatedAt = delta.ts;
119
+ return stats;
120
+ }
121
+
122
+ export function recordReadDelta(delta, env = process.env) {
123
+ const { cxDir, deltaLog } = readTrackerPaths(env);
124
+ mkdirSync(cxDir, { recursive: true });
125
+ appendFileSync(deltaLog, `${JSON.stringify(delta)}\n`, 'utf8');
126
+ }
127
+
128
+ export function flushReadTrackerDeltas({ nowIso = new Date().toISOString(), env = process.env } = {}) {
129
+ const { cxDir, deltaLog, efficiencyStore } = readTrackerPaths(env);
130
+ mkdirSync(cxDir, { recursive: true });
131
+ const stats = loadEfficiencyStats(nowIso, env);
132
+ if (!existsSync(deltaLog)) return stats;
133
+
134
+ const lines = readFileSync(deltaLog, 'utf8')
135
+ .split('\n')
136
+ .map((line) => line.trim())
137
+ .filter(Boolean);
138
+
139
+ for (const line of lines) {
140
+ try {
141
+ const delta = JSON.parse(line);
142
+ applyReadDelta(stats, delta, env);
143
+ } catch { /* best effort */ }
144
+ }
145
+
146
+ writeFileSync(efficiencyStore, `${JSON.stringify(stats, null, 2)}\n`);
147
+ rmSync(deltaLog, { force: true });
148
+ return stats;
149
+ }