@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.
- package/.env.example +1 -1
- package/README.md +4 -4
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +72 -11
- package/bin/construct-postinstall.mjs +1 -1
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +1 -2
- package/lib/beads-automation.mjs +16 -7
- package/lib/bootstrap/resources.mjs +2 -2
- package/lib/cli-commands.mjs +8 -2
- package/lib/document-ingest.mjs +6 -5
- package/lib/embed/cli.mjs +16 -3
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +38 -18
- package/lib/embed/daemon.mjs +59 -50
- package/lib/embed/inbox.mjs +30 -0
- package/lib/embed/recommendation-store.mjs +214 -15
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/evaluator-optimizer.mjs +0 -2
- package/lib/features.mjs +11 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +3 -5
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +119 -3
- package/lib/init-update.mjs +246 -131
- package/lib/install/first-invocation.mjs +4 -4
- package/lib/intake/queue.mjs +1 -1
- package/lib/integrations/intake-integrations.mjs +4 -5
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/rag.mjs +16 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-cheapest-provider.mjs +231 -0
- package/lib/model-router.mjs +68 -9
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/parity.mjs +124 -21
- package/lib/project-profile.mjs +1 -1
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/roles/catalog.mjs +133 -0
- package/lib/roles/preference.mjs +74 -0
- package/lib/server/index.mjs +109 -47
- package/lib/server/insights.mjs +1 -1
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +32 -24
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +2 -3
- package/lib/setup-prompts.mjs +2 -2
- package/lib/setup.mjs +55 -46
- package/lib/status.mjs +56 -8
- package/lib/storage/backend.mjs +12 -2
- package/lib/storage/postgres-backup.mjs +1 -1
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/worker/trace.mjs +17 -27
- package/package.json +5 -2
- package/rules/common/research.md +44 -12
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/data-analyst.product-intelligence.md +1 -1
- package/skills/roles/researcher.md +28 -15
- package/skills/routing.md +8 -1
- package/templates/docs/construct_guide.md +2 -2
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/strategy.md +36 -0
- 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
|
|
18
|
-
* opencode
|
|
19
|
-
* codex
|
|
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
|
|
63
|
-
|
|
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(
|
|
68
|
-
.map((name) => name.
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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: '
|
|
188
|
+
surface: 'vscode',
|
|
189
|
+
kind: 'mcps',
|
|
114
190
|
status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
|
|
115
|
-
|
|
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(', ')}`);
|
package/lib/project-profile.mjs
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/lib/prompt-composer.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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, {
|
|
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 >
|
|
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
|
-
|
|
164
|
-
|
|
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${
|
|
176
|
-
tokenBudget:
|
|
225
|
+
content: `### ${flavor} domain guidance\n\n${overlayContent}`,
|
|
226
|
+
tokenBudget: selectedProfile.roleFlavorTokens,
|
|
177
227
|
});
|
|
178
228
|
}
|
|
179
229
|
}
|
|
180
230
|
}
|
|
181
231
|
|
|
182
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
233
|
-
tokenBudget:
|
|
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,
|
|
247
|
-
tokenBudget:
|
|
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),
|
|
258
|
-
tokenBudget:
|
|
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,
|
|
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
|
+
}
|