@geraldmaron/construct 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -42
- 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 +279 -4
- package/commands/understand/research.md +5 -3
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +3 -4
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +52 -2
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/flavors/loader.mjs +136 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +25 -2
- package/lib/init-update.mjs +246 -131
- package/lib/intake/classify.mjs +61 -183
- package/lib/intake/prepare.mjs +7 -0
- package/lib/intake/tables/creative.mjs +94 -0
- package/lib/intake/tables/operations.mjs +85 -0
- package/lib/intake/tables/research.mjs +85 -0
- package/lib/intake/tables/rnd.mjs +175 -0
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/research-store.mjs +109 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/observation-store.mjs +19 -0
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +121 -21
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- 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/worker/trace.mjs +17 -27
- package/package.json +10 -2
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/rules/common/research.md +44 -12
- package/scripts/sync-agents.mjs +11 -0
- 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/architect.ai-systems.md +4 -2
- package/skills/roles/architect.data.md +4 -2
- package/skills/roles/architect.enterprise.md +4 -2
- package/skills/roles/architect.integration.md +4 -2
- package/skills/roles/architect.md +7 -5
- package/skills/roles/architect.platform.md +4 -2
- package/skills/roles/data-analyst.experiment.md +4 -2
- package/skills/roles/data-analyst.md +9 -7
- package/skills/roles/data-analyst.product-intelligence.md +5 -3
- package/skills/roles/data-analyst.product.md +4 -2
- package/skills/roles/data-analyst.telemetry.md +4 -2
- package/skills/roles/data-engineer.pipeline.md +4 -2
- package/skills/roles/data-engineer.vector-retrieval.md +4 -2
- package/skills/roles/data-engineer.warehouse.md +4 -2
- package/skills/roles/debugger.md +7 -5
- package/skills/roles/designer.accessibility.md +4 -2
- package/skills/roles/designer.md +10 -8
- package/skills/roles/engineer.ai.md +4 -2
- package/skills/roles/engineer.data.md +5 -3
- package/skills/roles/engineer.md +14 -12
- package/skills/roles/engineer.platform.md +5 -3
- package/skills/roles/operator.docs.md +6 -4
- package/skills/roles/operator.md +6 -4
- package/skills/roles/operator.release.md +4 -2
- package/skills/roles/operator.sre.md +5 -3
- package/skills/roles/orchestrator.md +5 -3
- package/skills/roles/product-manager.ai-product.md +4 -2
- package/skills/roles/product-manager.business-strategy.md +4 -2
- package/skills/roles/product-manager.enterprise.md +4 -2
- package/skills/roles/product-manager.growth.md +4 -2
- package/skills/roles/product-manager.md +6 -4
- package/skills/roles/product-manager.platform.md +4 -2
- package/skills/roles/product-manager.product.md +4 -2
- package/skills/roles/qa.ai-eval.md +4 -2
- package/skills/roles/qa.api-contract.md +4 -2
- package/skills/roles/qa.data-pipeline.md +4 -2
- package/skills/roles/qa.md +7 -5
- package/skills/roles/qa.test-automation.md +5 -3
- package/skills/roles/qa.web-ui.md +4 -2
- package/skills/roles/researcher.explorer.md +4 -2
- package/skills/roles/researcher.md +35 -20
- package/skills/roles/researcher.ux.md +4 -2
- package/skills/roles/reviewer.devil-advocate.md +4 -2
- package/skills/roles/reviewer.evaluator.md +4 -2
- package/skills/roles/reviewer.md +14 -12
- package/skills/roles/reviewer.trace.md +4 -2
- package/skills/roles/security.ai.md +4 -2
- package/skills/roles/security.appsec.md +4 -2
- package/skills/roles/security.cloud.md +4 -2
- package/skills/roles/security.legal-compliance.md +4 -2
- package/skills/roles/security.md +7 -5
- package/skills/roles/security.privacy.md +4 -2
- package/skills/roles/security.supply-chain.md +4 -2
- package/skills/routing.md +8 -1
- package/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +6 -6
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
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reflect/extractor.mjs — Deterministic transcript → observation extractor.
|
|
3
|
+
*
|
|
4
|
+
* Pure-function pass over a Claude Code transcript JSONL. Produces a compact
|
|
5
|
+
* structured summary suitable for `addObservation()` at session end. No LLM
|
|
6
|
+
* call — the transcript itself is the source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Caller responsibility: read the transcript file and pass parsed lines.
|
|
9
|
+
* Output is capped at MAX_BYTES to honor the A1 hook's per-session budget.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Hard cap on the summary content so a runaway session can't blow up the
|
|
13
|
+
// observation store. 5 KB matches the budget set in the A1 plan.
|
|
14
|
+
const MAX_BYTES = 5 * 1024;
|
|
15
|
+
const MAX_TAGS = 10;
|
|
16
|
+
const MAX_TOOL_TYPES = 8;
|
|
17
|
+
const MAX_FILES = 12;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract a session-summary observation from a parsed transcript.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} args
|
|
23
|
+
* @param {object[]} args.entries — parsed transcript JSONL lines (objects)
|
|
24
|
+
* @param {string} args.cwd — session working directory
|
|
25
|
+
* @param {string} [args.sessionId] — session identifier
|
|
26
|
+
* @param {number} [args.durationMs] — wall-clock session duration
|
|
27
|
+
* @returns {object|null} observation payload, or null if transcript is unusable
|
|
28
|
+
*/
|
|
29
|
+
export function extractSessionObservation({ entries, cwd, sessionId, durationMs }) {
|
|
30
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
const stats = collectStats(entries);
|
|
33
|
+
if (stats.assistantTurns === 0 && stats.userTurns === 0) return null;
|
|
34
|
+
|
|
35
|
+
const finalAssistant = stats.lastAssistantText;
|
|
36
|
+
const headline = deriveHeadline(finalAssistant, stats);
|
|
37
|
+
|
|
38
|
+
const lines = [];
|
|
39
|
+
lines.push(`Session: ${stats.userTurns} user turns, ${stats.assistantTurns} assistant turns`);
|
|
40
|
+
if (stats.toolCallCount > 0) {
|
|
41
|
+
const tools = Array.from(stats.toolTypes.entries())
|
|
42
|
+
.sort((a, b) => b[1] - a[1])
|
|
43
|
+
.slice(0, MAX_TOOL_TYPES)
|
|
44
|
+
.map(([name, n]) => `${name}×${n}`)
|
|
45
|
+
.join(', ');
|
|
46
|
+
lines.push(`Tools: ${stats.toolCallCount} calls (${tools})`);
|
|
47
|
+
}
|
|
48
|
+
if (stats.filesTouched.size > 0) {
|
|
49
|
+
const files = Array.from(stats.filesTouched).slice(0, MAX_FILES);
|
|
50
|
+
lines.push(`Files touched: ${files.join(', ')}${stats.filesTouched.size > MAX_FILES ? ', …' : ''}`);
|
|
51
|
+
}
|
|
52
|
+
if (durationMs && Number.isFinite(durationMs)) {
|
|
53
|
+
lines.push(`Duration: ${Math.round(durationMs / 1000)}s`);
|
|
54
|
+
}
|
|
55
|
+
if (finalAssistant) {
|
|
56
|
+
lines.push('', 'Final response excerpt:', clamp(finalAssistant, 800));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content = clamp(lines.join('\n'), MAX_BYTES);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
role: stats.lastAgent || 'construct',
|
|
63
|
+
category: 'session-summary',
|
|
64
|
+
summary: headline,
|
|
65
|
+
content,
|
|
66
|
+
tags: buildTags(stats),
|
|
67
|
+
confidence: 0.7,
|
|
68
|
+
source: 'auto-reflect',
|
|
69
|
+
extras: {
|
|
70
|
+
sessionId: sessionId || null,
|
|
71
|
+
cwd,
|
|
72
|
+
durationMs: durationMs ?? null,
|
|
73
|
+
userTurns: stats.userTurns,
|
|
74
|
+
assistantTurns: stats.assistantTurns,
|
|
75
|
+
toolCallCount: stats.toolCallCount,
|
|
76
|
+
filesTouched: Array.from(stats.filesTouched).slice(0, MAX_FILES),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Walk the transcript once and gather everything we need in one pass. Keeps
|
|
82
|
+
// the hook well under its latency budget even on long sessions.
|
|
83
|
+
|
|
84
|
+
function collectStats(entries) {
|
|
85
|
+
const toolTypes = new Map();
|
|
86
|
+
const filesTouched = new Set();
|
|
87
|
+
let assistantTurns = 0;
|
|
88
|
+
let userTurns = 0;
|
|
89
|
+
let toolCallCount = 0;
|
|
90
|
+
let lastAssistantText = '';
|
|
91
|
+
let lastAgent = null;
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
95
|
+
const type = entry.type;
|
|
96
|
+
|
|
97
|
+
if (type === 'user') {
|
|
98
|
+
userTurns += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (type === 'assistant') {
|
|
103
|
+
assistantTurns += 1;
|
|
104
|
+
const text = extractAssistantText(entry);
|
|
105
|
+
if (text) lastAssistantText = text;
|
|
106
|
+
const calls = extractToolCalls(entry);
|
|
107
|
+
for (const call of calls) {
|
|
108
|
+
toolCallCount += 1;
|
|
109
|
+
toolTypes.set(call.name, (toolTypes.get(call.name) || 0) + 1);
|
|
110
|
+
for (const f of call.files) filesTouched.add(f);
|
|
111
|
+
}
|
|
112
|
+
const agent = extractAgentName(entry);
|
|
113
|
+
if (agent) lastAgent = agent;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { assistantTurns, userTurns, toolCallCount, toolTypes, filesTouched, lastAssistantText, lastAgent };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractAssistantText(entry) {
|
|
121
|
+
const message = entry.message ?? entry;
|
|
122
|
+
const content = message?.content;
|
|
123
|
+
if (typeof content === 'string') return content.trim();
|
|
124
|
+
if (Array.isArray(content)) {
|
|
125
|
+
const text = content
|
|
126
|
+
.filter((p) => p?.type === 'text' && typeof p.text === 'string')
|
|
127
|
+
.map((p) => p.text)
|
|
128
|
+
.join('\n')
|
|
129
|
+
.trim();
|
|
130
|
+
return text;
|
|
131
|
+
}
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractToolCalls(entry) {
|
|
136
|
+
const message = entry.message ?? entry;
|
|
137
|
+
const content = message?.content;
|
|
138
|
+
if (!Array.isArray(content)) return [];
|
|
139
|
+
const calls = [];
|
|
140
|
+
for (const part of content) {
|
|
141
|
+
if (part?.type !== 'tool_use') continue;
|
|
142
|
+
const name = part.name || 'unknown';
|
|
143
|
+
const files = [];
|
|
144
|
+
const input = part.input || {};
|
|
145
|
+
if (typeof input.file_path === 'string') files.push(shortenPath(input.file_path));
|
|
146
|
+
if (typeof input.path === 'string') files.push(shortenPath(input.path));
|
|
147
|
+
calls.push({ name, files });
|
|
148
|
+
}
|
|
149
|
+
return calls;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractAgentName(entry) {
|
|
153
|
+
// Claude Code may stamp the agent name on subagent transcripts. Fall through
|
|
154
|
+
// to null if not present — caller defaults to 'construct'.
|
|
155
|
+
return entry?.agent || entry?.subagent_type || entry?.message?.agent || null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function deriveHeadline(finalText, stats) {
|
|
159
|
+
// First non-empty line of the final assistant turn is usually a good lead.
|
|
160
|
+
// Fall back to a structural summary when nothing useful is available.
|
|
161
|
+
if (finalText) {
|
|
162
|
+
const firstLine = finalText.split('\n').map((l) => l.trim()).find((l) => l.length > 0);
|
|
163
|
+
if (firstLine) {
|
|
164
|
+
const clean = firstLine.replace(/^[#*\->]\s*/, '').replace(/[`*_]/g, '');
|
|
165
|
+
return `[session] ${clamp(clean, 200)}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return `[session] ${stats.assistantTurns} turn${stats.assistantTurns === 1 ? '' : 's'}, ${stats.toolCallCount} tool call${stats.toolCallCount === 1 ? '' : 's'}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildTags(stats) {
|
|
172
|
+
const tags = ['auto-reflect', 'session-summary'];
|
|
173
|
+
// Surface the most-used tool family so search can filter on "Bash-heavy
|
|
174
|
+
// session", etc. without parsing content.
|
|
175
|
+
const top = Array.from(stats.toolTypes.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
176
|
+
for (const [name] of top) tags.push(`tool:${name.toLowerCase()}`);
|
|
177
|
+
if (stats.filesTouched.size > 0) tags.push('edits');
|
|
178
|
+
return tags.slice(0, MAX_TAGS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function shortenPath(p) {
|
|
182
|
+
if (typeof p !== 'string') return '';
|
|
183
|
+
// Strip a repo-root prefix when present so observations stay portable.
|
|
184
|
+
const cwd = process.cwd();
|
|
185
|
+
if (p.startsWith(cwd + '/')) return p.slice(cwd.length + 1);
|
|
186
|
+
return p;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clamp(str, max) {
|
|
190
|
+
if (!str) return '';
|
|
191
|
+
if (str.length <= max) return str;
|
|
192
|
+
return str.slice(0, max - 1) + '…';
|
|
193
|
+
}
|