@geraldmaron/construct 1.0.5 → 1.0.7

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 (268) hide show
  1. package/README.md +24 -18
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +5 -3
  5. package/agents/prompts/cx-ai-engineer.md +9 -7
  6. package/agents/prompts/cx-architect.md +8 -6
  7. package/agents/prompts/cx-business-strategist.md +8 -6
  8. package/agents/prompts/cx-data-analyst.md +9 -7
  9. package/agents/prompts/cx-data-engineer.md +5 -3
  10. package/agents/prompts/cx-debugger.md +8 -6
  11. package/agents/prompts/cx-designer.md +7 -5
  12. package/agents/prompts/cx-devil-advocate.md +6 -4
  13. package/agents/prompts/cx-docs-keeper.md +7 -5
  14. package/agents/prompts/cx-engineer.md +7 -5
  15. package/agents/prompts/cx-evaluator.md +4 -2
  16. package/agents/prompts/cx-explorer.md +10 -8
  17. package/agents/prompts/cx-legal-compliance.md +4 -2
  18. package/agents/prompts/cx-operations.md +7 -5
  19. package/agents/prompts/cx-orchestrator.md +12 -10
  20. package/agents/prompts/cx-platform-engineer.md +7 -5
  21. package/agents/prompts/cx-product-manager.md +6 -4
  22. package/agents/prompts/cx-qa.md +10 -8
  23. package/agents/prompts/cx-rd-lead.md +11 -9
  24. package/agents/prompts/cx-release-manager.md +7 -5
  25. package/agents/prompts/cx-researcher.md +24 -22
  26. package/agents/prompts/cx-reviewer.md +9 -7
  27. package/agents/prompts/cx-security.md +12 -10
  28. package/agents/prompts/cx-sre.md +9 -7
  29. package/agents/prompts/cx-test-automation.md +5 -3
  30. package/agents/prompts/cx-trace-reviewer.md +10 -8
  31. package/agents/prompts/cx-ux-researcher.md +5 -3
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +444 -53
  34. package/commands/build/feature.md +4 -4
  35. package/commands/build/fix.md +8 -8
  36. package/commands/design/access.md +3 -3
  37. package/commands/design/flow.md +3 -3
  38. package/commands/design/ui.md +4 -4
  39. package/commands/measure/experiment.md +5 -5
  40. package/commands/measure/metrics.md +3 -3
  41. package/commands/measure/results.md +4 -4
  42. package/commands/plan/api.md +3 -3
  43. package/commands/plan/challenge.md +3 -3
  44. package/commands/plan/decide.md +3 -3
  45. package/commands/plan/feature.md +7 -7
  46. package/commands/plan/requirements.md +3 -3
  47. package/commands/remember/context.md +5 -5
  48. package/commands/remember/handoff.md +3 -3
  49. package/commands/remember/runbook.md +3 -3
  50. package/commands/review/code.md +8 -8
  51. package/commands/review/quality.md +4 -4
  52. package/commands/review/security.md +3 -3
  53. package/commands/ship/ready.md +3 -3
  54. package/commands/ship/release.md +3 -3
  55. package/commands/ship/status.md +4 -4
  56. package/commands/understand/docs.md +3 -3
  57. package/commands/understand/this.md +4 -4
  58. package/commands/understand/why.md +8 -8
  59. package/commands/work/clean.md +14 -14
  60. package/commands/work/drive.md +10 -10
  61. package/commands/work/optimize-prompts.md +9 -9
  62. package/commands/work/parallel-review.md +8 -8
  63. package/db/schema/006_graph.sql +24 -0
  64. package/examples/provider-plugin/README.md +7 -7
  65. package/examples/seed-observations/README.md +6 -6
  66. package/examples/seed-observations/anti-patterns.md +14 -14
  67. package/examples/seed-observations/decisions.md +4 -4
  68. package/examples/seed-observations/patterns.md +14 -14
  69. package/lib/auto-docs.mjs +13 -8
  70. package/lib/boundary.mjs +126 -0
  71. package/lib/cache-strategy-google.js +26 -31
  72. package/lib/cli-commands.mjs +2 -2
  73. package/lib/comment-lint.mjs +134 -0
  74. package/lib/contracts/validate.mjs +323 -0
  75. package/lib/daemons/contract.mjs +210 -0
  76. package/lib/docs-verify.mjs +59 -6
  77. package/lib/doctor/cli.mjs +16 -1
  78. package/lib/doctor/index.mjs +3 -1
  79. package/lib/doctor/watchers/consistency.mjs +310 -0
  80. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  81. package/lib/document-extract.mjs +211 -1
  82. package/lib/embed/cli.mjs +124 -3
  83. package/lib/embed/daemon.mjs +43 -4
  84. package/lib/embed/docs-lifecycle.mjs +1 -1
  85. package/lib/embed/inbox.mjs +2 -0
  86. package/lib/embed/scheduler.mjs +33 -5
  87. package/lib/evaluator-optimizer.mjs +2 -3
  88. package/lib/flavors/loader.mjs +1 -1
  89. package/lib/hooks/comment-lint.mjs +16 -0
  90. package/lib/hooks/mcp-audit.mjs +2 -1
  91. package/lib/hooks/proactive-activation.mjs +0 -14
  92. package/lib/hooks/rule-verifier.mjs +217 -0
  93. package/lib/hooks/session-optimize.mjs +2 -1
  94. package/lib/init-unified.mjs +55 -65
  95. package/lib/intake/classify.mjs +108 -24
  96. package/lib/intake/daemon.mjs +121 -0
  97. package/lib/intake/filesystem-queue.mjs +6 -1
  98. package/lib/intake/intake-config.mjs +2 -1
  99. package/lib/intake/prepare.mjs +0 -1
  100. package/lib/intake/session-prelude.mjs +7 -1
  101. package/lib/intake/traceability.mjs +90 -0
  102. package/lib/knowledge/graph.mjs +213 -0
  103. package/lib/knowledge/research-store.mjs +2 -0
  104. package/lib/maintenance/cleanup.mjs +315 -0
  105. package/lib/mcp/memory-bridge.mjs +276 -0
  106. package/lib/mcp/server.mjs +189 -1
  107. package/lib/mcp/tools/profile.mjs +270 -0
  108. package/lib/mcp/tools/workflow.mjs +25 -0
  109. package/lib/mcp-catalog.json +12 -8
  110. package/lib/mcp-platform-config.mjs +16 -8
  111. package/lib/migrations/index.mjs +106 -0
  112. package/lib/migrations/v1-baseline.mjs +33 -0
  113. package/lib/observation-store.mjs +9 -4
  114. package/lib/outcomes/record.mjs +2 -0
  115. package/lib/profiles/rebrand.mjs +46 -0
  116. package/lib/project-init-shared.mjs +12 -0
  117. package/lib/provider-capabilities.js +20 -7
  118. package/lib/providers/auth-manager.mjs +58 -17
  119. package/lib/reflect.mjs +49 -12
  120. package/lib/server/index.mjs +22 -28
  121. package/lib/session-store.mjs +6 -4
  122. package/lib/setup.mjs +14 -3
  123. package/lib/telemetry/client.mjs +5 -1
  124. package/lib/version.mjs +51 -0
  125. package/lib/worker/trace.mjs +5 -1
  126. package/package.json +4 -1
  127. package/personas/construct.md +3 -1
  128. package/platforms/claude/CLAUDE.md +6 -6
  129. package/rules/common/agents.md +2 -2
  130. package/rules/common/beads-hygiene.md +11 -11
  131. package/rules/common/code-review.md +1 -1
  132. package/rules/common/coding-style.md +1 -1
  133. package/rules/common/comments.md +8 -8
  134. package/rules/common/commit-approval.md +4 -4
  135. package/rules/common/cx-agent-routing.md +2 -2
  136. package/rules/common/cx-skill-routing.md +2 -2
  137. package/rules/common/development-workflow.md +3 -2
  138. package/rules/common/doc-ownership.md +2 -2
  139. package/rules/common/efficiency.md +3 -3
  140. package/rules/common/framing.md +1 -1
  141. package/rules/common/git-workflow.md +1 -1
  142. package/rules/common/no-fabrication.md +69 -0
  143. package/rules/common/patterns.md +1 -1
  144. package/rules/common/performance.md +1 -1
  145. package/rules/common/release-gates.md +7 -7
  146. package/rules/common/research.md +4 -4
  147. package/rules/common/review-before-change.md +58 -0
  148. package/rules/common/security.md +1 -1
  149. package/rules/common/skill-composition.md +8 -8
  150. package/rules/common/testing.md +1 -1
  151. package/rules/golang/coding-style.md +2 -2
  152. package/rules/golang/hooks.md +1 -1
  153. package/rules/golang/patterns.md +1 -1
  154. package/rules/golang/security.md +1 -1
  155. package/rules/golang/testing.md +1 -1
  156. package/rules/python/coding-style.md +1 -1
  157. package/rules/python/hooks.md +1 -1
  158. package/rules/python/patterns.md +1 -1
  159. package/rules/python/security.md +1 -1
  160. package/rules/python/testing.md +1 -1
  161. package/rules/swift/coding-style.md +3 -3
  162. package/rules/swift/hooks.md +2 -2
  163. package/rules/swift/patterns.md +2 -2
  164. package/rules/swift/security.md +4 -4
  165. package/rules/swift/testing.md +2 -2
  166. package/rules/typescript/coding-style.md +1 -1
  167. package/rules/typescript/hooks.md +1 -1
  168. package/rules/typescript/patterns.md +1 -1
  169. package/rules/typescript/security.md +1 -1
  170. package/rules/typescript/testing.md +1 -1
  171. package/rules/web/coding-style.md +1 -1
  172. package/rules/web/design-quality.md +1 -1
  173. package/rules/web/hooks.md +1 -1
  174. package/rules/web/patterns.md +1 -1
  175. package/rules/web/performance.md +1 -1
  176. package/rules/web/security.md +1 -1
  177. package/rules/web/testing.md +1 -1
  178. package/scripts/sync-agents.mjs +45 -14
  179. package/skills/ai/agent-dev.md +1 -1
  180. package/skills/ai/llm-security.md +1 -1
  181. package/skills/ai/ml-ops.md +6 -6
  182. package/skills/ai/orchestration-workflow.md +1 -1
  183. package/skills/ai/prompt-and-eval.md +1 -1
  184. package/skills/ai/prompt-optimizer.md +13 -13
  185. package/skills/ai/rag-system.md +1 -1
  186. package/skills/architecture/api-design.md +1 -1
  187. package/skills/architecture/caching.md +1 -1
  188. package/skills/architecture/cloud-native.md +1 -1
  189. package/skills/architecture/message-queue.md +1 -1
  190. package/skills/architecture/security-arch.md +1 -1
  191. package/skills/compliance/ai-disclosure.md +1 -1
  192. package/skills/compliance/data-privacy.md +1 -1
  193. package/skills/compliance/license-audit.md +2 -2
  194. package/skills/compliance/regulatory-review.md +1 -1
  195. package/skills/development/cpp.md +1 -1
  196. package/skills/development/go.md +1 -1
  197. package/skills/development/java.md +1 -1
  198. package/skills/development/kotlin.md +9 -9
  199. package/skills/development/mobile-crossplatform.md +13 -13
  200. package/skills/development/python.md +1 -1
  201. package/skills/development/rust.md +1 -1
  202. package/skills/development/shell.md +1 -1
  203. package/skills/development/swift.md +6 -6
  204. package/skills/development/typescript.md +1 -1
  205. package/skills/devops/ci-cd.md +5 -5
  206. package/skills/devops/containerization.md +9 -9
  207. package/skills/devops/cost-optimization.md +1 -1
  208. package/skills/devops/data-engineering.md +2 -2
  209. package/skills/devops/database.md +1 -1
  210. package/skills/devops/dependency-management.md +3 -3
  211. package/skills/devops/devsecops.md +1 -1
  212. package/skills/devops/git-workflow.md +1 -1
  213. package/skills/devops/incident-response.md +18 -18
  214. package/skills/devops/monorepo.md +5 -5
  215. package/skills/devops/observability.md +1 -1
  216. package/skills/devops/performance.md +1 -1
  217. package/skills/devops/testing.md +1 -1
  218. package/skills/docs/adr-workflow.md +2 -2
  219. package/skills/docs/backlog-proposal-workflow.md +1 -1
  220. package/skills/docs/customer-profile-workflow.md +1 -1
  221. package/skills/docs/document-ingest-workflow.md +1 -1
  222. package/skills/docs/evidence-ingest-workflow.md +1 -1
  223. package/skills/docs/init-docs.md +15 -15
  224. package/skills/docs/init-project.md +1 -1
  225. package/skills/docs/prd-workflow.md +3 -3
  226. package/skills/docs/prfaq-workflow.md +1 -1
  227. package/skills/docs/product-intelligence-review.md +1 -1
  228. package/skills/docs/product-intelligence-workflow.md +1 -1
  229. package/skills/docs/product-signal-workflow.md +9 -9
  230. package/skills/docs/research-workflow.md +10 -10
  231. package/skills/docs/runbook-workflow.md +2 -2
  232. package/skills/docs/strategy-workflow.md +3 -3
  233. package/skills/exploration/repo-map.md +11 -11
  234. package/skills/frameworks/django.md +15 -15
  235. package/skills/frameworks/nextjs.md +16 -16
  236. package/skills/frameworks/react.md +12 -12
  237. package/skills/frameworks/spring-boot.md +12 -12
  238. package/skills/frontend-design/accessibility.md +6 -6
  239. package/skills/frontend-design/component-patterns.md +1 -1
  240. package/skills/frontend-design/engineering.md +1 -1
  241. package/skills/frontend-design/state-management.md +1 -1
  242. package/skills/frontend-design/ui-aesthetics.md +1 -1
  243. package/skills/frontend-design/ux-principles.md +1 -1
  244. package/skills/operating/orchestration-reference.md +27 -27
  245. package/skills/quality-gates/review-work.md +3 -3
  246. package/skills/quality-gates/verify-change.md +1 -1
  247. package/skills/quality-gates/verify-module.md +1 -1
  248. package/skills/quality-gates/verify-quality.md +1 -1
  249. package/skills/quality-gates/verify-security.md +1 -1
  250. package/skills/routing.md +14 -14
  251. package/skills/security/blue-team.md +1 -1
  252. package/skills/security/code-audit.md +1 -1
  253. package/skills/security/pentest.md +1 -1
  254. package/skills/security/red-team.md +1 -1
  255. package/skills/security/threat-intel.md +1 -1
  256. package/skills/security/vuln-research.md +1 -1
  257. package/skills/utility/clean-code.md +2 -2
  258. package/templates/docs/changelog-entry.md +1 -1
  259. package/templates/docs/construct_guide.md +18 -18
  260. package/templates/docs/meta-prd.md +16 -16
  261. package/templates/docs/one-pager.md +1 -1
  262. package/templates/docs/prd-business.md +1 -1
  263. package/templates/docs/prd-platform.md +1 -1
  264. package/templates/docs/prd.md +17 -17
  265. package/templates/docs/research-brief.md +8 -8
  266. package/templates/docs/rfc.md +1 -1
  267. package/templates/docs/skill-artifact.md +1 -1
  268. package/templates/docs/strategy.md +1 -1
@@ -0,0 +1,213 @@
1
+ /**
2
+ * lib/knowledge/graph.mjs — GraphRAG-style entity index + global query.
3
+ *
4
+ * Phase C9 foundations:
5
+ * - buildGraph(rootDir): derive nodes + edges from `.cx/observations/entities.json`.
6
+ * - detectCommunities(graph): pure-JS label propagation. Deterministic
7
+ * (sorted neighbor scan, lowest-id tiebreak). O(V+E) per iteration.
8
+ * - summarizeCommunity(community, entities): extractive summary over the
9
+ * member entities, ranked by degree centrality. Requires no LLM call,
10
+ * so the primitive works in solo mode without a provider configured.
11
+ * - askGlobal({ query, rootDir }): rank communities by overlap with the
12
+ * query terms + their members' search text, return the top communities
13
+ * with member entities and the question they best answer.
14
+ *
15
+ * Full Leiden (modularity optimization + refinement) would need an external
16
+ * graph library and is blocked by ADR-0001 (zero-npm-core). Label propagation
17
+ * is the deterministic alternative. Produces useful coarse communities on
18
+ * the small graphs Construct sessions accumulate (typically <500 entities).
19
+ * Swap implementations later by replacing `detectCommunities` only.
20
+ */
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { rankByBm25 } from '../storage/embeddings.mjs';
24
+
25
+ const OBS_DIR = '.cx/observations';
26
+ const ENTITIES_FILE = 'entities.json';
27
+
28
+ const MAX_LP_ITERATIONS = 30;
29
+ const MAX_COMMUNITY_TOP_TERMS = 8;
30
+ const MIN_COMMUNITY_SIZE = 2;
31
+
32
+ function readEntities(rootDir) {
33
+ const p = path.join(rootDir, OBS_DIR, ENTITIES_FILE);
34
+ if (!existsSync(p)) return [];
35
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return []; }
36
+ }
37
+
38
+ /**
39
+ * Build an undirected graph from the entity store. Nodes are entity names;
40
+ * edges come from `relatedEntities[]`. Returns:
41
+ * { nodes: Map<name, entity>, adj: Map<name, Set<name>> }
42
+ */
43
+ export function buildGraph(rootDir) {
44
+ const entities = readEntities(rootDir);
45
+ const nodes = new Map();
46
+ const adj = new Map();
47
+ for (const e of entities) {
48
+ if (!e?.name) continue;
49
+ nodes.set(e.name, e);
50
+ if (!adj.has(e.name)) adj.set(e.name, new Set());
51
+ }
52
+ for (const e of entities) {
53
+ if (!e?.name) continue;
54
+ for (const other of e.relatedEntities || []) {
55
+ if (!nodes.has(other)) continue;
56
+ adj.get(e.name).add(other);
57
+ adj.get(other).add(e.name);
58
+ }
59
+ }
60
+ return { nodes, adj };
61
+ }
62
+
63
+ /**
64
+ * Run synchronous label propagation. Each node starts with its own label;
65
+ * iteratively adopt the most frequent label among neighbors, with a sorted
66
+ * neighbor scan and lowest-id tiebreak so the result is deterministic.
67
+ *
68
+ * Returns: Map<name, communityId> plus a stable grouping.
69
+ */
70
+ export function detectCommunities(graph) {
71
+ const labels = new Map();
72
+ const sortedNodes = [...graph.nodes.keys()].sort();
73
+ for (const n of sortedNodes) labels.set(n, n);
74
+
75
+ for (let iter = 0; iter < MAX_LP_ITERATIONS; iter++) {
76
+ let changed = false;
77
+ for (const node of sortedNodes) {
78
+ const neighbors = [...(graph.adj.get(node) || [])].sort();
79
+ if (neighbors.length === 0) continue;
80
+
81
+ const counts = new Map();
82
+ for (const nb of neighbors) {
83
+ const lbl = labels.get(nb);
84
+ counts.set(lbl, (counts.get(lbl) || 0) + 1);
85
+ }
86
+
87
+ // highest-count label, lowest-id tiebreak
88
+ let bestLabel = labels.get(node);
89
+ let bestCount = -1;
90
+ const sortedLabels = [...counts.entries()].sort((a, b) => {
91
+ if (b[1] !== a[1]) return b[1] - a[1];
92
+ return a[0] < b[0] ? -1 : 1;
93
+ });
94
+ if (sortedLabels.length > 0) {
95
+ [bestLabel, bestCount] = sortedLabels[0];
96
+ }
97
+
98
+ if (bestCount > 0 && bestLabel !== labels.get(node)) {
99
+ labels.set(node, bestLabel);
100
+ changed = true;
101
+ }
102
+ }
103
+ if (!changed) break;
104
+ }
105
+
106
+ const communities = new Map();
107
+ for (const [node, label] of labels) {
108
+ if (!communities.has(label)) communities.set(label, []);
109
+ communities.get(label).push(node);
110
+ }
111
+ // Stable order: by size desc, then alphabetical on canonical label
112
+ const grouped = [...communities.entries()]
113
+ .map(([id, members]) => ({ id, members: members.sort() }))
114
+ .sort((a, b) => {
115
+ if (b.members.length !== a.members.length) return b.members.length - a.members.length;
116
+ return a.id < b.id ? -1 : 1;
117
+ });
118
+ return { labels, communities: grouped };
119
+ }
120
+
121
+ /**
122
+ * Extractive community summary. Picks the top-N members by intra-community
123
+ * degree (the "central" nodes of the cluster) and harvests their summaries
124
+ * into a short text block. Deterministic and dependency-free.
125
+ */
126
+ export function summarizeCommunity(group, graph) {
127
+ if (!group?.members?.length) return { id: group?.id, size: 0, topMembers: [], summary: '' };
128
+ const memberSet = new Set(group.members);
129
+ const scored = group.members.map((name) => {
130
+ const neighbors = graph.adj.get(name) || new Set();
131
+ let degree = 0;
132
+ for (const nb of neighbors) if (memberSet.has(nb)) degree++;
133
+ return { name, degree };
134
+ }).sort((a, b) => {
135
+ if (b.degree !== a.degree) return b.degree - a.degree;
136
+ return a.name < b.name ? -1 : 1;
137
+ });
138
+
139
+ const topMembers = scored.slice(0, MAX_COMMUNITY_TOP_TERMS);
140
+ const lines = [];
141
+ for (const { name } of topMembers) {
142
+ const entity = graph.nodes.get(name);
143
+ if (!entity) continue;
144
+ const summary = (entity.summary || '').trim();
145
+ if (summary) lines.push(`- ${entity.name}: ${summary}`);
146
+ else lines.push(`- ${entity.name}`);
147
+ }
148
+ return {
149
+ id: group.id,
150
+ size: group.members.length,
151
+ topMembers: topMembers.map((m) => m.name),
152
+ summary: lines.join('\n'),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Global query over the entity graph. Builds the graph, detects communities,
158
+ * scores each community by BM25 over its concatenated member summaries, and
159
+ * returns the top communities with members.
160
+ *
161
+ * Intentionally returns structured data instead of a synthesized prose
162
+ * answer: the LM-based response synthesis is the next layer up and belongs
163
+ * in the persona prompt, not in this retrieval primitive. Solo mode can
164
+ * consume the raw result without calling an external model.
165
+ *
166
+ * @param {object} args
167
+ * @param {string} args.query - natural-language question
168
+ * @param {string} args.rootDir - project root containing `.cx/observations/`
169
+ * @param {number} [args.topK=5] - max communities to return
170
+ * @param {number} [args.minSize=MIN_COMMUNITY_SIZE] - skip singleton communities
171
+ * @returns {{ query, communities: Array<{ id, size, topMembers, summary, score }>, totalEntities, totalCommunities }}
172
+ */
173
+ export function askGlobal({ query, rootDir, topK = 5, minSize = MIN_COMMUNITY_SIZE } = {}) {
174
+ if (!query || typeof query !== 'string') {
175
+ return { query: query || '', communities: [], totalEntities: 0, totalCommunities: 0 };
176
+ }
177
+ const graph = buildGraph(rootDir);
178
+ if (graph.nodes.size === 0) {
179
+ return { query, communities: [], totalEntities: 0, totalCommunities: 0 };
180
+ }
181
+
182
+ const { communities } = detectCommunities(graph);
183
+ const eligible = communities.filter((g) => g.members.length >= minSize);
184
+ if (eligible.length === 0) {
185
+ return {
186
+ query,
187
+ communities: [],
188
+ totalEntities: graph.nodes.size,
189
+ totalCommunities: communities.length,
190
+ };
191
+ }
192
+
193
+ const summaries = eligible.map((group) => summarizeCommunity(group, graph));
194
+ const docs = summaries.map((s, i) => ({
195
+ id: String(i),
196
+ text: `${s.topMembers.join(' ')}\n${s.summary}`,
197
+ }));
198
+ const ranked = rankByBm25(docs, query, { limit: topK });
199
+
200
+ const out = [];
201
+ for (const r of ranked) {
202
+ const summary = summaries[Number(r.id)];
203
+ if (!summary) continue;
204
+ out.push({ ...summary, score: r.score });
205
+ }
206
+
207
+ return {
208
+ query,
209
+ communities: out,
210
+ totalEntities: graph.nodes.size,
211
+ totalCommunities: communities.length,
212
+ };
213
+ }
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { syncFileStateToSql } from '../storage/sync.mjs';
21
21
  import { resolveActiveProfile } from '../profiles/loader.mjs';
22
+ import { ensureCxDir } from '../project-init-shared.mjs';
22
23
 
23
24
  const ROOT = '.cx/knowledge/external/research';
24
25
  const MAX_BYTES = 50 * 1024;
@@ -68,6 +69,7 @@ export async function addResearchFinding({
68
69
  const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
69
70
 
70
71
  const outDir = join(cwd, ROOT);
72
+ ensureCxDir(cwd);
71
73
  mkdirSync(outDir, { recursive: true });
72
74
  const outPath = join(outDir, `${slug}.md`);
73
75
 
@@ -0,0 +1,315 @@
1
+ /**
2
+ * lib/maintenance/cleanup.mjs — Construct's self-maintenance primitives.
3
+ *
4
+ * Every artifact Construct writes has a lifecycle. This module collects the
5
+ * primitives that enforce that lifecycle: rotate oversized logs, prune
6
+ * rotated segments, age-out cache directories. Each primitive is pure and
7
+ * returns a structured summary; runFullCleanup orchestrates them with sane
8
+ * defaults.
9
+ *
10
+ * Triggered from two entry points:
11
+ * - `construct cleanup` — manual sweep, optionally --dry-run
12
+ * - bin/construct startup check — automatic when the installed version
13
+ * differs from ~/.construct/.cleanup-stamp,
14
+ * so an upgrade reclaims disk without
15
+ * requiring a manual command.
16
+ *
17
+ * Scope is deliberately narrow: only artifacts whose retention is owned by
18
+ * the runtime (logs, caches, rotated segments). Knowledge stores
19
+ * (.cx/observations/, .cx/handoffs/, .cx/intake/) require explicit retention
20
+ * policy and are left for a follow-up after policy is agreed.
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ const HARD_BUDGET_MS = 5000;
28
+
29
+ // Size caps in bytes. Each entry: [filename relative to ~/.cx/, maxBytes].
30
+ // Files that exceed maxBytes are truncated (head-preserving rotation is not
31
+ // useful for append-only event logs — the most-recent events are what matter,
32
+ // not the oldest; tail-preserving truncation keeps the last N% by recopying).
33
+ const JSONL_LOG_CAPS_BYTES = {
34
+ 'audit.jsonl': 25 * 1024 * 1024,
35
+ 'contract-violations.jsonl': 25 * 1024 * 1024,
36
+ 'doctor-log.jsonl': 25 * 1024 * 1024,
37
+ 'role-pending.jsonl': 5 * 1024 * 1024,
38
+ 'role-events.jsonl': 25 * 1024 * 1024,
39
+ };
40
+
41
+ const EMBED_LOG_DEFAULT_MAX_MB = 50;
42
+ const EMBED_LOG_DEFAULT_KEEP = 3;
43
+ const EMBED_LOG_HARD_CAP_MAX_MB = 500;
44
+ const EMBED_LOG_HARD_CAP_KEEP = 20;
45
+
46
+ function fileSizeOrZero(p) {
47
+ try { return fs.statSync(p).size; } catch { return 0; }
48
+ }
49
+
50
+ function rmSafe(p, dryRun) {
51
+ if (dryRun) return;
52
+ try { fs.rmSync(p, { force: true }); } catch (err) {
53
+ process.stderr.write(`[cleanup] failed to remove ${p}: ${err.message}\n`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Tail-preserving truncate: keeps the last `keepBytes` of `file`, drops the
59
+ * rest. Used for append-only event logs where recent events are valuable but
60
+ * historical bloat is not.
61
+ */
62
+ function tailTruncate(file, keepBytes, dryRun) {
63
+ if (dryRun) return;
64
+ let stat;
65
+ try { stat = fs.statSync(file); } catch { return; }
66
+ if (stat.size <= keepBytes) return;
67
+ const start = stat.size - keepBytes;
68
+ const buf = Buffer.alloc(keepBytes);
69
+ const fd = fs.openSync(file, 'r');
70
+ try { fs.readSync(fd, buf, 0, keepBytes, start); } finally { fs.closeSync(fd); }
71
+ // Find the first newline so we don't start mid-record.
72
+ const nl = buf.indexOf(0x0a);
73
+ const payload = nl >= 0 && nl < buf.length - 1 ? buf.subarray(nl + 1) : buf;
74
+ fs.writeFileSync(file, payload);
75
+ }
76
+
77
+ function rotationConfig(env = process.env) {
78
+ const maxMbRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_MAX_MB ?? '', 10);
79
+ const keepRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_KEEP ?? '', 10);
80
+ const maxMb = Number.isFinite(maxMbRaw) && maxMbRaw > 0 ? Math.min(maxMbRaw, EMBED_LOG_HARD_CAP_MAX_MB) : EMBED_LOG_DEFAULT_MAX_MB;
81
+ const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? Math.min(keepRaw, EMBED_LOG_HARD_CAP_KEEP) : EMBED_LOG_DEFAULT_KEEP;
82
+ return { maxBytes: maxMb * 1024 * 1024, keep };
83
+ }
84
+
85
+ /**
86
+ * Walk the embed log + every rotated segment (.1, .2, ...) and drop the ones
87
+ * that exceed the per-segment cap OR sit beyond the keep count.
88
+ *
89
+ * Unlike rotateEmbedLogIfNeeded in lib/embed/cli.mjs (which is called at
90
+ * daemon spawn time and only rotates the live log), this is the post-upgrade
91
+ * cleanup: it handles the case where an existing install has segments that
92
+ * were oversized before the cap existed.
93
+ */
94
+ export function cleanupEmbedLog({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
95
+ const dir = path.join(homeDir, '.cx', 'runtime');
96
+ const baseLog = path.join(dir, 'embed-daemon.log');
97
+ const { maxBytes, keep } = rotationConfig(env);
98
+
99
+ const summary = { removed: [], truncated: [], freedBytes: 0, baseLog };
100
+
101
+ // Every existing segment (.log, .log.1, .log.2, ...)
102
+ if (!fs.existsSync(dir)) return summary;
103
+ const entries = fs.readdirSync(dir).filter(n => n === 'embed-daemon.log' || n.startsWith('embed-daemon.log.'));
104
+
105
+ for (const name of entries) {
106
+ const full = path.join(dir, name);
107
+ const size = fileSizeOrZero(full);
108
+
109
+ // Drop segments beyond the keep horizon entirely.
110
+ const dotMatch = name.match(/^embed-daemon\.log\.(\d+)$/);
111
+ if (dotMatch && Number.parseInt(dotMatch[1], 10) > keep) {
112
+ summary.removed.push({ path: full, size });
113
+ summary.freedBytes += size;
114
+ rmSafe(full, dryRun);
115
+ continue;
116
+ }
117
+
118
+ // The live log: if oversized, truncate to 0 (it will rotate cleanly on next
119
+ // daemon spawn via rotateEmbedLogIfNeeded). Truncating in place keeps any
120
+ // open daemon FDs valid (writes resume at byte 0 on next O_APPEND write).
121
+ if (name === 'embed-daemon.log' && size > maxBytes) {
122
+ summary.truncated.push({ path: full, sizeWas: size });
123
+ summary.freedBytes += size;
124
+ if (!dryRun) {
125
+ try {
126
+ fs.truncateSync(full, 0);
127
+ } catch (err) {
128
+ process.stderr.write(`[cleanup] failed to truncate ${full}: ${err.message}\n`);
129
+ }
130
+ }
131
+ continue;
132
+ }
133
+
134
+ // Rotated segments that are individually oversized: drop them. They are
135
+ // archived noise; keeping them around defeats the cap.
136
+ if (dotMatch && size > maxBytes) {
137
+ summary.removed.push({ path: full, size });
138
+ summary.freedBytes += size;
139
+ rmSafe(full, dryRun);
140
+ }
141
+ }
142
+
143
+ return summary;
144
+ }
145
+
146
+ /**
147
+ * Apply per-file size caps to the user-level JSONL event logs at ~/.cx/.
148
+ * Truncates oldest events rather than removing the whole file so the most
149
+ * recent records survive.
150
+ */
151
+ export function cleanupJsonlLogs({ homeDir = os.homedir(), dryRun = false } = {}) {
152
+ const dir = path.join(homeDir, '.cx');
153
+ const summary = { truncated: [], freedBytes: 0 };
154
+ if (!fs.existsSync(dir)) return summary;
155
+
156
+ for (const [name, capBytes] of Object.entries(JSONL_LOG_CAPS_BYTES)) {
157
+ const file = path.join(dir, name);
158
+ const size = fileSizeOrZero(file);
159
+ if (size <= capBytes) continue;
160
+ // Retain the most recent half-cap so headroom exists before next truncate.
161
+ const keepBytes = Math.floor(capBytes / 2);
162
+ summary.truncated.push({ path: file, sizeWas: size, sizeNow: keepBytes });
163
+ summary.freedBytes += size - keepBytes;
164
+ tailTruncate(file, keepBytes, dryRun);
165
+ }
166
+
167
+ return summary;
168
+ }
169
+
170
+ /**
171
+ * Age out ~/.construct/cache/ entries older than `maxAgeDays`. Cache hits are
172
+ * idempotent and re-fetched on demand, so pruning is safe.
173
+ */
174
+ export function cleanupCacheDir({ homeDir = os.homedir(), maxAgeDays = 30, dryRun = false } = {}) {
175
+ const dir = path.join(homeDir, '.construct', 'cache');
176
+ const summary = { removed: [], freedBytes: 0 };
177
+ if (!fs.existsSync(dir)) return summary;
178
+
179
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
180
+ const walk = (current) => {
181
+ let entries;
182
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
183
+ for (const entry of entries) {
184
+ const full = path.join(current, entry.name);
185
+ if (entry.isDirectory()) { walk(full); continue; }
186
+ let stat;
187
+ try { stat = fs.statSync(full); } catch { continue; }
188
+ if (stat.mtimeMs < cutoff) {
189
+ summary.removed.push({ path: full, size: stat.size });
190
+ summary.freedBytes += stat.size;
191
+ rmSafe(full, dryRun);
192
+ }
193
+ }
194
+ };
195
+ walk(dir);
196
+ return summary;
197
+ }
198
+
199
+ /**
200
+ * Orchestrator. Runs each primitive under a hard time budget and returns an
201
+ * aggregated summary. Individual failures are non-fatal: a primitive that
202
+ * throws is recorded as an error in the summary but does not block the rest.
203
+ */
204
+ export function runFullCleanup({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
205
+ const startedAt = Date.now();
206
+ const summary = {
207
+ dryRun,
208
+ startedAt: new Date(startedAt).toISOString(),
209
+ durationMs: 0,
210
+ freedBytes: 0,
211
+ embedLog: null,
212
+ jsonlLogs: null,
213
+ cacheDir: null,
214
+ errors: [],
215
+ };
216
+
217
+ const safelyRun = (label, fn) => {
218
+ if (Date.now() - startedAt > HARD_BUDGET_MS) {
219
+ summary.errors.push({ step: label, error: 'time budget exceeded; skipped' });
220
+ return null;
221
+ }
222
+ try { return fn(); }
223
+ catch (err) { summary.errors.push({ step: label, error: err.message }); return null; }
224
+ };
225
+
226
+ summary.embedLog = safelyRun('embedLog', () => cleanupEmbedLog({ homeDir, env, dryRun }));
227
+ summary.jsonlLogs = safelyRun('jsonlLogs', () => cleanupJsonlLogs({ homeDir, dryRun }));
228
+ summary.cacheDir = safelyRun('cacheDir', () => cleanupCacheDir({ homeDir, dryRun }));
229
+
230
+ for (const part of [summary.embedLog, summary.jsonlLogs, summary.cacheDir]) {
231
+ if (part?.freedBytes) summary.freedBytes += part.freedBytes;
232
+ }
233
+ summary.durationMs = Date.now() - startedAt;
234
+ return summary;
235
+ }
236
+
237
+ /**
238
+ * Format bytes for one-line operator output. 1234567 → "1.2 MB".
239
+ */
240
+ export function formatBytes(bytes) {
241
+ if (!bytes || bytes < 0) return '0 B';
242
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
243
+ let n = bytes;
244
+ let unit = 0;
245
+ while (n >= 1024 && unit < units.length - 1) { n /= 1024; unit += 1; }
246
+ return `${n.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Version stamp — drives automatic cleanup on upgrade.
251
+ //
252
+ // ~/.construct/.cleanup-stamp records the last version that ran cleanup.
253
+ // On every CLI invocation, bin/construct compares the current package
254
+ // version to the stamp. A mismatch triggers a single cleanup pass and
255
+ // writes the new version into the stamp. The first ever invocation also
256
+ // triggers cleanup (no stamp present) — this is what unblocks existing
257
+ // installs that already have 34 GB logs sitting around.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ const STAMP_RELATIVE = path.join('.construct', '.cleanup-stamp');
261
+
262
+ export function stampPath(homeDir = os.homedir()) {
263
+ return path.join(homeDir, STAMP_RELATIVE);
264
+ }
265
+
266
+ export function readVersionStamp({ homeDir = os.homedir() } = {}) {
267
+ const p = stampPath(homeDir);
268
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
269
+ catch { return null; }
270
+ }
271
+
272
+ export function writeVersionStamp({ homeDir = os.homedir(), version, summary } = {}) {
273
+ const p = stampPath(homeDir);
274
+ fs.mkdirSync(path.dirname(p), { recursive: true });
275
+ const payload = {
276
+ version,
277
+ ranAt: new Date().toISOString(),
278
+ freedBytes: summary?.freedBytes ?? 0,
279
+ durationMs: summary?.durationMs ?? 0,
280
+ };
281
+ fs.writeFileSync(p, JSON.stringify(payload, null, 2));
282
+ }
283
+
284
+ /**
285
+ * Run cleanup if and only if the installed version differs from the stamp.
286
+ * Returns { ran: bool, reason, summary?, previousVersion? }.
287
+ *
288
+ * Designed to be called at CLI startup. Never throws — cleanup failures are
289
+ * surfaced via the returned summary and never block the actual command.
290
+ */
291
+ export function maybeRunCleanupOnUpgrade({
292
+ homeDir = os.homedir(),
293
+ env = process.env,
294
+ currentVersion,
295
+ force = false,
296
+ } = {}) {
297
+ if (env.CONSTRUCT_DISABLE_AUTO_CLEANUP === '1') {
298
+ return { ran: false, reason: 'disabled via CONSTRUCT_DISABLE_AUTO_CLEANUP' };
299
+ }
300
+ const stamp = readVersionStamp({ homeDir });
301
+ if (!force && stamp && stamp.version === currentVersion) {
302
+ return { ran: false, reason: 'version matches stamp' };
303
+ }
304
+ let summary;
305
+ try { summary = runFullCleanup({ homeDir, env, dryRun: false }); }
306
+ catch (err) { return { ran: false, reason: `cleanup threw: ${err.message}` }; }
307
+ try { writeVersionStamp({ homeDir, version: currentVersion, summary }); }
308
+ catch (err) { /* stamp write failure is non-fatal */ void err; }
309
+ return {
310
+ ran: true,
311
+ reason: stamp ? 'version changed' : 'first run',
312
+ previousVersion: stamp?.version ?? null,
313
+ summary,
314
+ };
315
+ }