@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.
Files changed (157) hide show
  1. package/README.md +51 -42
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +279 -4
  19. package/commands/understand/research.md +5 -3
  20. package/db/schema/004_recommendations.sql +46 -0
  21. package/db/schema/005_strategy.sql +21 -0
  22. package/lib/auto-docs.mjs +3 -4
  23. package/lib/beads-automation.mjs +16 -7
  24. package/lib/cli-commands.mjs +52 -2
  25. package/lib/comment-lint.mjs +7 -1
  26. package/lib/config/schema.mjs +3 -0
  27. package/lib/embed/conflict-detection.mjs +26 -9
  28. package/lib/embed/customer-profiles.mjs +37 -17
  29. package/lib/embed/daemon.mjs +10 -8
  30. package/lib/embed/recommendation-store.mjs +213 -14
  31. package/lib/embed/workspaces.mjs +53 -18
  32. package/lib/flavors/loader.mjs +136 -0
  33. package/lib/gates-audit.mjs +3 -3
  34. package/lib/health-check.mjs +1 -1
  35. package/lib/hooks/agent-tracker.mjs +22 -3
  36. package/lib/hooks/pre-compact.mjs +3 -0
  37. package/lib/hooks/pre-push-gate.mjs +14 -1
  38. package/lib/hooks/read-tracker.mjs +10 -101
  39. package/lib/hooks/session-optimize.mjs +3 -2
  40. package/lib/hooks/session-reflect.mjs +68 -0
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +25 -2
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/intake/classify.mjs +61 -183
  45. package/lib/intake/prepare.mjs +7 -0
  46. package/lib/intake/tables/creative.mjs +94 -0
  47. package/lib/intake/tables/operations.mjs +85 -0
  48. package/lib/intake/tables/research.mjs +85 -0
  49. package/lib/intake/tables/rnd.mjs +175 -0
  50. package/lib/intent-classifier.mjs +1 -0
  51. package/lib/knowledge/layout.mjs +10 -0
  52. package/lib/knowledge/research-store.mjs +109 -0
  53. package/lib/mcp/tools/telemetry.mjs +30 -78
  54. package/lib/model-router.mjs +61 -1
  55. package/lib/observation-store.mjs +19 -0
  56. package/lib/ollama-manager.mjs +1 -1
  57. package/lib/opencode-telemetry.mjs +4 -5
  58. package/lib/orchestration-policy.mjs +9 -0
  59. package/lib/outcomes/aggregate.mjs +104 -0
  60. package/lib/outcomes/record.mjs +115 -0
  61. package/lib/parity.mjs +121 -21
  62. package/lib/profiles/lifecycle.mjs +388 -0
  63. package/lib/profiles/loader.mjs +123 -0
  64. package/lib/profiles/validate-custom.mjs +114 -0
  65. package/lib/prompt-composer.js +106 -29
  66. package/lib/read-tracker-store.mjs +149 -0
  67. package/lib/reflect/extractor.mjs +193 -0
  68. package/lib/reflect.mjs +89 -2
  69. package/lib/sandbox.mjs +102 -0
  70. package/lib/server/index.mjs +76 -0
  71. package/lib/server/telemetry-login.mjs +17 -25
  72. package/lib/service-manager.mjs +30 -22
  73. package/lib/services/local-postgres.mjs +15 -0
  74. package/lib/services/telemetry-backend.mjs +1 -2
  75. package/lib/setup.mjs +8 -43
  76. package/lib/status.mjs +51 -5
  77. package/lib/storage/backend.mjs +12 -2
  78. package/lib/strategy-store.mjs +371 -0
  79. package/lib/telemetry/backends/local.mjs +6 -4
  80. package/lib/telemetry/client.mjs +185 -0
  81. package/lib/telemetry/ingest.mjs +13 -5
  82. package/lib/telemetry/team-rollup.mjs +9 -2
  83. package/lib/worker/trace.mjs +17 -27
  84. package/package.json +10 -2
  85. package/personas/construct.md +20 -20
  86. package/platforms/claude/settings.template.json +13 -0
  87. package/rules/common/research.md +44 -12
  88. package/scripts/sync-agents.mjs +11 -0
  89. package/skills/docs/backlog-proposal-workflow.md +2 -2
  90. package/skills/docs/customer-profile-workflow.md +1 -1
  91. package/skills/docs/evidence-ingest-workflow.md +5 -5
  92. package/skills/docs/prfaq-workflow.md +1 -1
  93. package/skills/docs/product-intelligence-review.md +1 -1
  94. package/skills/docs/product-intelligence-workflow.md +3 -3
  95. package/skills/docs/product-signal-workflow.md +48 -18
  96. package/skills/docs/research-workflow.md +26 -14
  97. package/skills/docs/strategy-workflow.md +36 -0
  98. package/skills/roles/architect.ai-systems.md +4 -2
  99. package/skills/roles/architect.data.md +4 -2
  100. package/skills/roles/architect.enterprise.md +4 -2
  101. package/skills/roles/architect.integration.md +4 -2
  102. package/skills/roles/architect.md +7 -5
  103. package/skills/roles/architect.platform.md +4 -2
  104. package/skills/roles/data-analyst.experiment.md +4 -2
  105. package/skills/roles/data-analyst.md +9 -7
  106. package/skills/roles/data-analyst.product-intelligence.md +5 -3
  107. package/skills/roles/data-analyst.product.md +4 -2
  108. package/skills/roles/data-analyst.telemetry.md +4 -2
  109. package/skills/roles/data-engineer.pipeline.md +4 -2
  110. package/skills/roles/data-engineer.vector-retrieval.md +4 -2
  111. package/skills/roles/data-engineer.warehouse.md +4 -2
  112. package/skills/roles/debugger.md +7 -5
  113. package/skills/roles/designer.accessibility.md +4 -2
  114. package/skills/roles/designer.md +10 -8
  115. package/skills/roles/engineer.ai.md +4 -2
  116. package/skills/roles/engineer.data.md +5 -3
  117. package/skills/roles/engineer.md +14 -12
  118. package/skills/roles/engineer.platform.md +5 -3
  119. package/skills/roles/operator.docs.md +6 -4
  120. package/skills/roles/operator.md +6 -4
  121. package/skills/roles/operator.release.md +4 -2
  122. package/skills/roles/operator.sre.md +5 -3
  123. package/skills/roles/orchestrator.md +5 -3
  124. package/skills/roles/product-manager.ai-product.md +4 -2
  125. package/skills/roles/product-manager.business-strategy.md +4 -2
  126. package/skills/roles/product-manager.enterprise.md +4 -2
  127. package/skills/roles/product-manager.growth.md +4 -2
  128. package/skills/roles/product-manager.md +6 -4
  129. package/skills/roles/product-manager.platform.md +4 -2
  130. package/skills/roles/product-manager.product.md +4 -2
  131. package/skills/roles/qa.ai-eval.md +4 -2
  132. package/skills/roles/qa.api-contract.md +4 -2
  133. package/skills/roles/qa.data-pipeline.md +4 -2
  134. package/skills/roles/qa.md +7 -5
  135. package/skills/roles/qa.test-automation.md +5 -3
  136. package/skills/roles/qa.web-ui.md +4 -2
  137. package/skills/roles/researcher.explorer.md +4 -2
  138. package/skills/roles/researcher.md +35 -20
  139. package/skills/roles/researcher.ux.md +4 -2
  140. package/skills/roles/reviewer.devil-advocate.md +4 -2
  141. package/skills/roles/reviewer.evaluator.md +4 -2
  142. package/skills/roles/reviewer.md +14 -12
  143. package/skills/roles/reviewer.trace.md +4 -2
  144. package/skills/roles/security.ai.md +4 -2
  145. package/skills/roles/security.appsec.md +4 -2
  146. package/skills/roles/security.cloud.md +4 -2
  147. package/skills/roles/security.legal-compliance.md +4 -2
  148. package/skills/roles/security.md +7 -5
  149. package/skills/roles/security.privacy.md +4 -2
  150. package/skills/roles/security.supply-chain.md +4 -2
  151. package/skills/routing.md +8 -1
  152. package/templates/docs/persona-artifact.md +36 -0
  153. package/templates/docs/research-brief.md +63 -9
  154. package/templates/docs/research-finding.md +26 -0
  155. package/templates/docs/skill-artifact.md +27 -0
  156. package/templates/docs/strategy.md +36 -0
  157. package/templates/homebrew/construct.rb +6 -6
@@ -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
+ }
@@ -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
+ }