@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
@@ -1,240 +0,0 @@
1
- /**
2
- * lib/intake/postgres-queue.mjs — Postgres adapter for the IntakeQueue interface.
3
- *
4
- * Backs team and enterprise deployment modes. Implements the same six-method
5
- * contract as FilesystemIntakeQueue (enqueue, listPending, count, read,
6
- * markProcessed, markSkipped, reopen) plus a claim() method that uses
7
- * SELECT ... FOR UPDATE SKIP LOCKED so concurrent workers cannot grab the
8
- * same pending item.
9
- *
10
- * Triage fields (intake_type, rd_stage, primary_owner, recommended_action,
11
- * risk, requires_approval, confidence) are flattened into typed columns for
12
- * efficient filtering; the full packet — including suggestion / related /
13
- * excerpt / query — lives in payload jsonb so the on-disk and in-table
14
- * shapes round-trip without loss.
15
- */
16
-
17
- import path from 'node:path';
18
- import { shouldQuarantine } from './quarantine.mjs';
19
-
20
- function slugify(value) {
21
- return String(value || 'untitled')
22
- .toLowerCase()
23
- .replace(/[^a-z0-9]+/g, '-')
24
- .replace(/^-+|-+$/g, '')
25
- .slice(0, 60) || 'untitled';
26
- }
27
-
28
- let counter = 0;
29
- function timestamp() {
30
- // Milliseconds + a per-process counter to guarantee uniqueness even when
31
- // two enqueues fire in the same millisecond (test loops, batched ingests).
32
- counter = (counter + 1) % 1000;
33
- const c = String(counter).padStart(3, '0');
34
- return `${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23)}-${c}`;
35
- }
36
-
37
- function rowToEntry(row) {
38
- if (!row) return null;
39
- const payload = row.payload || {};
40
- return {
41
- id: row.id,
42
- createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
43
- status: row.status,
44
- ...payload,
45
- processedAt: row.processed_at instanceof Date ? row.processed_at.toISOString() : row.processed_at || undefined,
46
- processedBy: row.processed_by || undefined,
47
- notes: row.notes || undefined,
48
- skippedAt: row.skipped_at instanceof Date ? row.skipped_at.toISOString() : row.skipped_at || undefined,
49
- skippedBy: row.skipped_by || undefined,
50
- reason: row.skip_reason || undefined,
51
- claimedAt: row.claimed_at instanceof Date ? row.claimed_at.toISOString() : row.claimed_at || undefined,
52
- claimedBy: row.claimed_by || undefined,
53
- };
54
- }
55
-
56
- export class PostgresIntakeQueue {
57
- constructor({ sql, project, tenantId = null } = {}) {
58
- if (!sql) throw new Error('PostgresIntakeQueue: sql client is required');
59
- if (!project) throw new Error('PostgresIntakeQueue: project is required');
60
- this.sql = sql;
61
- this.project = project;
62
- this.tenantId = tenantId;
63
- }
64
-
65
- async enqueue(entry) {
66
- if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
67
-
68
- const ts = timestamp();
69
- const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
70
- const id = `${ts}-${slug}`;
71
- const triage = entry.triage || {};
72
-
73
- // Quarantine routing: low-confidence packets land in status='quarantined'
74
- // instead of 'pending' so worker claim() never picks them up. Human
75
- // reroute via rerouteQuarantined() promotes to 'pending'.
76
- const quarantineDecision = shouldQuarantine(triage);
77
- const status = quarantineDecision.quarantine ? 'quarantined' : 'pending';
78
- const augmentedEntry = quarantineDecision.quarantine
79
- ? { ...entry, quarantineReason: quarantineDecision.reason }
80
- : entry;
81
-
82
- await this.sql`
83
- INSERT INTO construct_intake_items (
84
- id, project, tenant_id, status,
85
- intake_type, rd_stage, primary_owner, recommended_action,
86
- risk, requires_approval, confidence, payload
87
- ) VALUES (
88
- ${id}, ${this.project}, ${this.tenantId}, ${status},
89
- ${triage.intakeType || null}, ${triage.rdStage || null},
90
- ${triage.primaryOwner || null}, ${triage.recommendedAction || null},
91
- ${triage.risk || null}, ${triage.requiresApproval || false},
92
- ${typeof triage.confidence === 'number' ? triage.confidence : null},
93
- ${this.sql.json(augmentedEntry)}
94
- )
95
- `;
96
- return { id, route: quarantineDecision.quarantine ? 'quarantine' : 'pending', reason: quarantineDecision.reason };
97
- }
98
-
99
- async listQuarantine({ limit = 100 } = {}) {
100
- const rows = await this.sql`
101
- SELECT * FROM construct_intake_items
102
- WHERE project = ${this.project}
103
- AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
104
- AND status = 'quarantined'
105
- ORDER BY created_at ASC
106
- LIMIT ${limit}
107
- `;
108
- return rows.map(rowToEntry);
109
- }
110
-
111
- async rerouteQuarantined(id, newType, { reroutedBy = 'unknown', reason = '' } = {}) {
112
- const result = await this.sql`
113
- UPDATE construct_intake_items
114
- SET status = 'pending',
115
- intake_type = ${newType},
116
- payload = jsonb_set(
117
- jsonb_set(payload, '{triage,originalIntakeType}', to_jsonb(intake_type::text)),
118
- '{triage,intakeType}', to_jsonb(${newType}::text)
119
- ),
120
- updated_at = now(),
121
- notes = ${`rerouted by ${reroutedBy}${reason ? `: ${reason}` : ''}`}
122
- WHERE id = ${id}
123
- AND project = ${this.project}
124
- AND status = 'quarantined'
125
- RETURNING id
126
- `;
127
- if (result.count === 0) throw new Error(`rerouteQuarantined: no quarantined entry ${id}`);
128
- return { id, route: 'pending' };
129
- }
130
-
131
- async listPending({ limit = 100 } = {}) {
132
- const rows = await this.sql`
133
- SELECT * FROM construct_intake_items
134
- WHERE project = ${this.project}
135
- AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
136
- AND status = 'pending'
137
- ORDER BY created_at ASC
138
- LIMIT ${limit}
139
- `;
140
- return rows.map(rowToEntry);
141
- }
142
-
143
- async count() {
144
- const rows = await this.sql`
145
- SELECT COUNT(*)::int AS n FROM construct_intake_items
146
- WHERE project = ${this.project}
147
- AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
148
- AND status = 'pending'
149
- `;
150
- return rows[0]?.n ?? 0;
151
- }
152
-
153
- async read(id) {
154
- const rows = await this.sql`
155
- SELECT * FROM construct_intake_items
156
- WHERE id = ${id}
157
- AND project = ${this.project}
158
- AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
159
- LIMIT 1
160
- `;
161
- return rows[0] ? rowToEntry(rows[0]) : null;
162
- }
163
-
164
- async markProcessed(id, { processedBy = 'unknown', notes = '' } = {}) {
165
- const result = await this.sql`
166
- UPDATE construct_intake_items
167
- SET status = 'processed', processed_at = now(), processed_by = ${processedBy},
168
- notes = ${notes || null}, updated_at = now()
169
- WHERE id = ${id}
170
- AND project = ${this.project}
171
- AND status IN ('pending', 'claimed')
172
- RETURNING id
173
- `;
174
- if (result.count === 0) throw new Error(`markProcessed: no pending entry ${id}`);
175
- return { id };
176
- }
177
-
178
- async markSkipped(id, { skippedBy = 'unknown', reason = '' } = {}) {
179
- const result = await this.sql`
180
- UPDATE construct_intake_items
181
- SET status = 'skipped', skipped_at = now(), skipped_by = ${skippedBy},
182
- skip_reason = ${reason || null}, updated_at = now()
183
- WHERE id = ${id}
184
- AND project = ${this.project}
185
- AND status IN ('pending', 'claimed')
186
- RETURNING id
187
- `;
188
- if (result.count === 0) throw new Error(`markSkipped: no pending entry ${id}`);
189
- return { id };
190
- }
191
-
192
- async reopen(id) {
193
- const result = await this.sql`
194
- UPDATE construct_intake_items
195
- SET status = 'pending',
196
- processed_at = NULL, processed_by = NULL, notes = NULL,
197
- skipped_at = NULL, skipped_by = NULL, skip_reason = NULL,
198
- claimed_at = NULL, claimed_by = NULL,
199
- updated_at = now()
200
- WHERE id = ${id}
201
- AND project = ${this.project}
202
- AND status IN ('processed', 'skipped')
203
- RETURNING id, (CASE WHEN processed_at IS NOT NULL THEN 'processed' ELSE 'skipped' END) AS from_status
204
- `;
205
- if (result.count === 0) throw new Error(`reopen: no processed or skipped entry ${id}`);
206
- return { id, from: result[0]?.from_status || 'unknown' };
207
- }
208
-
209
- /**
210
- * Atomically claim the next pending intake item for a worker.
211
- * Uses SELECT ... FOR UPDATE SKIP LOCKED so concurrent workers across
212
- * processes / containers cannot grab the same item. Returns the claimed
213
- * entry (with status='claimed'), or null when the queue is empty.
214
- */
215
- async claim({ claimedBy }) {
216
- if (!claimedBy) throw new Error('claim: claimedBy is required');
217
- return await this.sql.begin(async (tx) => {
218
- const rows = await tx`
219
- SELECT * FROM construct_intake_items
220
- WHERE project = ${this.project}
221
- AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
222
- AND status = 'pending'
223
- ORDER BY created_at ASC
224
- FOR UPDATE SKIP LOCKED
225
- LIMIT 1
226
- `;
227
- const row = rows[0];
228
- if (!row) return null;
229
- await tx`
230
- UPDATE construct_intake_items
231
- SET status = 'claimed', claimed_at = now(), claimed_by = ${claimedBy}, updated_at = now()
232
- WHERE id = ${row.id}
233
- `;
234
- row.status = 'claimed';
235
- row.claimed_by = claimedBy;
236
- row.claimed_at = new Date();
237
- return rowToEntry(row);
238
- });
239
- }
240
- }
@@ -1 +0,0 @@
1
- <!DOCTYPE html><html lang="en" data-theme="dark" data-density="comfortable" data-motion="normal" class="geist_478ec610-module__Maz4kG__variable geist_d633ac5f-module__BboVrW__variable jetbrains_mono_dc177b0e-module__78ed6G__variable"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/70bc3e132a0a741e-s.p.3t6q91iet4nsy.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="preload" href="/_next/static/media/caa3a2e1cccd8315-s.p.0wgildi0cnwt9.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="stylesheet" href="/_next/static/chunks/07baqf-hmk6oo.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/0i88v4ql6s7aw.js"/><script src="/_next/static/chunks/26rq-xwqn81a7.js" async=""></script><script src="/_next/static/chunks/1q2ld_2skrq5q.js" async=""></script><script src="/_next/static/chunks/41yml7ep3qrnx.js" async=""></script><script src="/_next/static/chunks/turbopack-1wenwimq-yd36.js" async=""></script><script src="/_next/static/chunks/39l2haooi0nh6.js" async=""></script><script src="/_next/static/chunks/1-8s9_t85wwr4.js" async=""></script><script src="/_next/static/chunks/0cangwgy_395t.js" async=""></script><meta name="next-size-adjust" content=""/><title>Construct — Dashboard</title><meta name="description" content="Local operations dashboard for Construct — approvals, health, knowledge, models, providers."/><script src="/_next/static/chunks/0cz1d0mv5g_q7.js" noModule=""></script></head><body><div hidden=""><!--$--><!--/$--></div><div class="shell"><header class="topbar"><a class="brand" href="/"><div class="mark"></div><div class="name">Construct<em>dashboard</em></div></a><div class="search-wrap"><label class="search"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.5" y2="16.5"></line></svg><input readOnly="" placeholder="Search dashboard…"/><span class="kbd">⌘K</span></label></div><div class="top-actions"><button class="icon-btn" title="Toggle theme" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"></circle><line x1="12" y1="2" x2="12" y2="5"></line><line x1="12" y1="19" x2="12" y2="22"></line><line x1="2" y1="12" x2="5" y2="12"></line><line x1="19" y1="12" x2="22" y2="12"></line><line x1="4.5" y1="4.5" x2="6.5" y2="6.5"></line><line x1="17.5" y1="17.5" x2="19.5" y2="19.5"></line><line x1="4.5" y1="19.5" x2="6.5" y2="17.5"></line><line x1="17.5" y1="6.5" x2="19.5" y2="4.5"></line></svg></button><button class="icon-btn" title="Toggle density" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="7" x2="21" y2="7"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="17" x2="21" y2="17"></line></svg></button><a class="icon-btn outlined" href="https://github.com/geraldmaron/construct" target="_blank" rel="noreferrer"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 .5C5.65.5.5 5.66.5 12.02c0 5.09 3.29 9.4 7.86 10.92.58.1.79-.25.79-.55v-2.13c-3.2.7-3.87-1.37-3.87-1.37-.52-1.32-1.27-1.67-1.27-1.67-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.76 2.69 1.25 3.35.96.1-.74.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.16 1.18a10.9 10.9 0 0 1 5.74 0c2.2-1.49 3.16-1.18 3.16-1.18.62 1.58.23 2.75.12 3.04.74.81 1.18 1.84 1.18 3.1 0 4.43-2.69 5.41-5.25 5.69.41.36.78 1.05.78 2.13v3.16c0 .31.21.66.79.55A11.52 11.52 0 0 0 23.5 12.02C23.5 5.66 18.35.5 12 .5Z"></path></svg> <span style="font-size:11px">repo</span></a></div><div class="progress"></div></header><div class="body-grid"><aside class="sidebar"><div class="side-group"><div class="side-label">Overview</div><a class="side-link active" href="/"><span class="num">01</span><span>Home</span></a><a class="side-link" href="/doctor/"><span class="num">02</span><span>Doctor</span></a><a class="side-link" href="/resources/"><span class="num">03</span><span>Services</span></a></div><div class="side-group"><div class="side-label">Activity</div><a class="side-link" href="/audit/"><span class="num">01</span><span>Audit trail</span></a><a class="side-link" href="/snapshots/"><span class="num">02</span><span>Snapshots</span></a><a class="side-link" href="/performance/"><span class="num">03</span><span>Performance</span></a></div><div class="side-group"><div class="side-label">Work</div><a class="side-link" href="/approvals/"><span class="num">01</span><span>Approvals</span></a><a class="side-link" href="/workflow/"><span class="num">02</span><span>Workflow</span></a><a class="side-link" href="/beads/"><span class="num">03</span><span>Beads</span></a><a class="side-link" href="/artifacts/"><span class="num">04</span><span>Artifacts</span></a><a class="side-link" href="/intake/"><span class="num">05</span><span>Intake</span></a></div><div class="side-group"><div class="side-label">Specialists</div><a class="side-link" href="/agents/"><span class="num">01</span><span>Specialists</span></a><a class="side-link" href="/skills/"><span class="num">02</span><span>Skills</span></a><a class="side-link" href="/commands/"><span class="num">03</span><span>Slash commands</span></a><a class="side-link" href="/hooks/"><span class="num">04</span><span>Hooks</span></a><a class="side-link" href="/plugins/"><span class="num">05</span><span>Plugins</span></a></div><div class="side-group"><div class="side-label">Models &amp; Providers</div><a class="side-link" href="/models/"><span class="num">01</span><span>Models</span></a><a class="side-link" href="/providers/"><span class="num">02</span><span>Providers</span></a><a class="side-link" href="/mcp/"><span class="num">03</span><span>MCP servers</span></a></div><div class="side-group"><div class="side-label">Knowledge</div><a class="side-link" href="/knowledge/"><span class="num">01</span><span>Knowledge</span></a><a class="side-link" href="/editor/"><span class="num">02</span><span>Editor</span></a></div><div class="side-group"><div class="side-label">System</div><a class="side-link" href="/config/"><span class="num">01</span><span>Config</span></a><a class="side-link" href="/infrastructure/"><span class="num">02</span><span>Infrastructure</span></a></div><div class="side-foot">Local dashboard for Construct. Theme + density preferences persist.</div></aside><main class="main"><div class="page"><div class="eyebrow"><span class="dot"></span><span>overview · construct dashboard</span></div><h1 class="page-title">System state</h1><p class="page-lede">The runtime in one place. Approvals, services, model providers, knowledge corpus, and intake — each section deep-links to its dedicated surface.</p><div class="meta-strip"><span class="pill">mode: <!-- -->…</span><span>instance: <!-- -->…</span><span class="sep">·</span><span>embed: <!-- -->stopped</span></div><div class="body"><div class="hero-stats" style="margin-top:0;margin-bottom:24px"><div class="hero-stat"><span class="k">Services</span><span class="v">…</span><span style="display:block;font-size:11.5px;color:var(--muted);margin-top:4px">running</span></div><div class="hero-stat"><span class="k">Approvals</span><span class="v">…</span><span style="display:block;font-size:11.5px;color:var(--muted);margin-top:4px">pending</span></div><div class="hero-stat"><span class="k">Skills</span><span class="v">0</span><span style="display:block;font-size:11.5px;color:var(--muted);margin-top:4px">registered</span></div><div class="hero-stat"><span class="k">Commands</span><span class="v">0</span><span style="display:block;font-size:11.5px;color:var(--muted);margin-top:4px">slash</span></div></div><section class="section open"><button class="section-head" aria-expanded="true" type="button"><span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"></polyline></svg></span><span style="display:flex;flex-direction:column;gap:6px"><span class="section-num">01</span><span class="section-title">Pending approvals</span></span><span class="section-aside"><span>empty</span></span></button><div class="tldr-row"><div class="tldr"><b>TL;DR</b>Items waiting on a human gate. High-risk mutations (work item creation, merge, doc publish, config changes) land here.</div></div><div class="section-body-wrap"><div class="section-body"><div class="section-body-inner"><div class="body"><div style="padding:24px;text-align:center;color:var(--muted);font-family:var(--mono);font-size:12px">Loading…</div></div></div></div></div></section><section class="section"><button class="section-head" aria-expanded="false" type="button"><span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"></polyline></svg></span><span style="display:flex;flex-direction:column;gap:6px"><span class="section-num">02</span><span class="section-title">Services</span></span><span class="section-aside"><span>0/0 running</span></span></button><div class="tldr-row"><div class="tldr"><b>TL;DR</b>Local processes that back the runtime: Postgres, memory bridge, OpenCode bridge, dashboard, embed daemon.</div></div><div class="section-body-wrap"><div class="section-body"><div class="section-body-inner"><div class="body"><div style="padding:24px;text-align:center;color:var(--muted);font-family:var(--mono);font-size:12px">Loading…</div><p style="margin-top:12px"><a class="link" href="/resources/">Open services dashboard →</a></p></div></div></div></div></section><section class="section"><button class="section-head" aria-expanded="false" type="button"><span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"></polyline></svg></span><span style="display:flex;flex-direction:column;gap:6px"><span class="section-num">03</span><span class="section-title">Credentials</span></span><span class="section-aside"><span>0 tracked</span></span></button><div class="tldr-row"><div class="tldr"><b>TL;DR</b>Provider API keys, billing posture, and 1Password references. Set them in /providers.</div></div><div class="section-body-wrap"><div class="section-body"><div class="section-body-inner"><div class="body"><p style="margin-top:12px"><a class="link" href="/providers/">Manage providers →</a></p></div></div></div></div></section><section class="section"><button class="section-head" aria-expanded="false" type="button"><span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"></polyline></svg></span><span style="display:flex;flex-direction:column;gap:6px"><span class="section-num">04</span><span class="section-title">Quick links</span></span><span class="section-aside"></span></button><div class="tldr-row"><div class="tldr"><b>TL;DR</b>Deep links into every dashboard surface.</div></div><div class="section-body-wrap"><div class="section-body"><div class="section-body-inner"><div class="body"><div class="callout"><span class="clt-label">Where to next</span><div><p><a class="link" href="/doctor/">Doctor</a>,<!-- --> <a class="link" href="/knowledge/">Knowledge</a>,<!-- --> <a class="link" href="/intake/">Intake</a>,<!-- --> <a class="link" href="/workflow/">Workflow</a>,<!-- --> <a class="link" href="/agents/">Specialists</a>,<!-- --> <a class="link" href="/models/">Models</a>,<!-- --> <a class="link" href="/audit/">Audit</a>.</p></div></div></div></div></div></div></section></div></div><!--$--><!--/$--></main></div></div><script src="/_next/static/chunks/0i88v4ql6s7aw.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[95327,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"AppShellClient\"]\n3:I[39756,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"default\"]\n4:I[37457,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"default\"]\n5:I[47257,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"ClientPageRoot\"]\n6:I[62453,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\",\"/_next/static/chunks/0cangwgy_395t.js\"],\"default\"]\n9:I[97367,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"OutletBoundary\"]\na:\"$Sreact.suspense\"\nc:I[97367,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"ViewportBoundary\"]\ne:I[97367,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"MetadataBoundary\"]\n10:I[68027,[\"/_next/static/chunks/39l2haooi0nh6.js\",\"/_next/static/chunks/1-8s9_t85wwr4.js\"],\"default\",1]\n:HL[\"/_next/static/chunks/07baqf-hmk6oo.css\",\"style\"]\n:HL[\"/_next/static/media/70bc3e132a0a741e-s.p.3t6q91iet4nsy.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/media/caa3a2e1cccd8315-s.p.0wgildi0cnwt9.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"c\":[\"\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",16],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/chunks/07baqf-hmk6oo.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"script\",\"script-0\",{\"src\":\"/_next/static/chunks/39l2haooi0nh6.js\",\"async\":true,\"nonce\":\"$undefined\"}],[\"$\",\"script\",\"script-1\",{\"src\":\"/_next/static/chunks/1-8s9_t85wwr4.js\",\"async\":true,\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"data-theme\":\"dark\",\"data-density\":\"comfortable\",\"data-motion\":\"normal\",\"suppressHydrationWarning\":true,\"className\":\"geist_478ec610-module__Maz4kG__variable geist_d633ac5f-module__BboVrW__variable jetbrains_mono_dc177b0e-module__78ed6G__variable\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L2\",null,{\"nav\":[{\"label\":\"Overview\",\"items\":[{\"id\":\"home\",\"title\":\"Home\",\"href\":\"/\"},{\"id\":\"doctor\",\"title\":\"Doctor\",\"href\":\"/doctor\"},{\"id\":\"resources\",\"title\":\"Services\",\"href\":\"/resources\"}]},{\"label\":\"Activity\",\"items\":[{\"id\":\"audit\",\"title\":\"Audit trail\",\"href\":\"/audit\"},{\"id\":\"snapshots\",\"title\":\"Snapshots\",\"href\":\"/snapshots\"},{\"id\":\"performance\",\"title\":\"Performance\",\"href\":\"/performance\"}]},{\"label\":\"Work\",\"items\":[{\"id\":\"approvals\",\"title\":\"Approvals\",\"href\":\"/approvals\"},{\"id\":\"workflow\",\"title\":\"Workflow\",\"href\":\"/workflow\"},{\"id\":\"beads\",\"title\":\"Beads\",\"href\":\"/beads\"},{\"id\":\"artifacts\",\"title\":\"Artifacts\",\"href\":\"/artifacts\"},{\"id\":\"intake\",\"title\":\"Intake\",\"href\":\"/intake\"}]},{\"label\":\"Specialists\",\"items\":[{\"id\":\"agents\",\"title\":\"Specialists\",\"href\":\"/agents\"},{\"id\":\"skills\",\"title\":\"Skills\",\"href\":\"/skills\"},{\"id\":\"commands\",\"title\":\"Slash commands\",\"href\":\"/commands\"},{\"id\":\"hooks\",\"title\":\"Hooks\",\"href\":\"/hooks\"},{\"id\":\"plugins\",\"title\":\"Plugins\",\"href\":\"/plugins\"}]},{\"label\":\"Models \u0026 Providers\",\"items\":[{\"id\":\"models\",\"title\":\"Models\",\"href\":\"/models\"},{\"id\":\"providers\",\"title\":\"Providers\",\"href\":\"/providers\"},{\"id\":\"mcp\",\"title\":\"MCP servers\",\"href\":\"/mcp\"}]},{\"label\":\"Knowledge\",\"items\":[{\"id\":\"knowledge\",\"title\":\"Knowledge\",\"href\":\"/knowledge\"},{\"id\":\"editor\",\"title\":\"Editor\",\"href\":\"/editor\"}]},{\"label\":\"System\",\"items\":[{\"id\":\"config\",\"title\":\"Config\",\"href\":\"/config\"},{\"id\":\"infrastructure\",\"title\":\"Infrastructure\",\"href\":\"/infrastructure\"}]}],\"children\":[\"$\",\"$L3\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L4\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L5\",null,{\"Component\":\"$6\",\"serverProvidedParams\":{\"searchParams\":{},\"params\":{},\"promises\":[\"$@7\",\"$@8\"]}}],[[\"$\",\"script\",\"script-0\",{\"src\":\"/_next/static/chunks/0cangwgy_395t.js\",\"async\":true,\"nonce\":\"$undefined\"}]],[\"$\",\"$L9\",null,{\"children\":[\"$\",\"$a\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@b\"}]}]]}],{},null,false,null]},null,false,null],[\"$\",\"$1\",\"h\",{\"children\":[null,[\"$\",\"$Lc\",null,{\"children\":\"$Ld\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$Le\",null,{\"children\":[\"$\",\"$a\",null,{\"name\":\"Next.Metadata\",\"children\":\"$Lf\"}]}]}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$10\",[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/chunks/07baqf-hmk6oo.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]]],\"S\":true,\"h\":null,\"s\":\"$undefined\",\"l\":\"$undefined\",\"p\":\"$undefined\",\"d\":\"$undefined\",\"b\":\"piZVVlR5yulp6NxkELzTZ\"}\n"])</script><script>self.__next_f.push([1,"7:{}\n8:\"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params\"\n"])</script><script>self.__next_f.push([1,"d:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\n"])</script><script>self.__next_f.push([1,"b:null\nf:[[\"$\",\"title\",\"0\",{\"children\":\"Construct — Dashboard\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Local operations dashboard for Construct — approvals, health, knowledge, models, providers.\"}]]\n"])</script></body></html>
@@ -1,15 +0,0 @@
1
- /**
2
- * lib/services/local-postgres.mjs — local Postgres helper exports.
3
- *
4
- * Compatibility wrappers live in telemetry-backend.mjs until downstream imports
5
- * move, but new service-management code should use this name.
6
- */
7
- export {
8
- POSTGRES_LOCAL_PORT,
9
- isRemoteTelemetry,
10
- servicesComposePath,
11
- pruneStashDir,
12
- verifyPostgresHealth,
13
- verifyTelemetryKeys,
14
- startManagedServices,
15
- } from './telemetry-backend.mjs';
@@ -1,347 +0,0 @@
1
- /**
2
- * lib/storage/backup.mjs — full Construct backup and restore.
3
- *
4
- * A backup archive is a gzip-compressed tar containing:
5
- *
6
- * manifest.json — version, timestamp, contents list, checksums
7
- * postgres/ — pg_dump output (if Postgres is reachable)
8
- * observations/ — ~/.cx/observations/*.json
9
- * sessions/ — ~/.cx/sessions/*.json
10
- * config.env — ~/.construct/config.env (secrets stripped unless opts.includeSecrets)
11
- * registry.json — specialists/registry.json snapshot
12
- *
13
- * The manifest uses SHA-256 checksums over each included file so
14
- * `backup verify` can detect corruption without extracting.
15
- *
16
- * Restore is atomic: files are written to a temp directory first,
17
- * then moved into place in one pass. Postgres restore is the last step
18
- * because it is the hardest to roll back.
19
- */
20
-
21
- import fs from 'node:fs';
22
- import os from 'node:os';
23
- import path from 'node:path';
24
- import crypto from 'node:crypto';
25
- import { execFileSync } from 'node:child_process';
26
-
27
- const HOME = os.homedir();
28
- const CX_DIR = path.join(HOME, '.cx');
29
- const CONSTRUCT_DIR = path.join(HOME, '.construct');
30
- const BACKUP_DIR = path.join(CONSTRUCT_DIR, 'backups');
31
-
32
- // Secret env var patterns to redact in config.env.
33
- const SECRET_KEYS = [
34
- /TOKEN/i, /PASSWORD/i, /SECRET/i, /KEY/i, /CREDENTIAL/i,
35
- /ANTHROPIC_API/i, /GITHUB_TOKEN/i, /SLACK_BOT/i, /SALESFORCE_ACCESS/i,
36
- /JIRA_API/i, /LINEAR_API/i,
37
- ];
38
-
39
- function isSecretKey(key) {
40
- return SECRET_KEYS.some((re) => re.test(key));
41
- }
42
-
43
- function redactConfigEnv(content) {
44
- return content.split('\n').map((line) => {
45
- const trimmed = line.trim();
46
- if (!trimmed || trimmed.startsWith('#')) return line;
47
- const eq = trimmed.indexOf('=');
48
- if (eq === -1) return line;
49
- const key = trimmed.slice(0, eq).trim();
50
- if (isSecretKey(key)) return `${key}=<redacted>`;
51
- return line;
52
- }).join('\n');
53
- }
54
-
55
- function sha256File(filePath) {
56
- const hash = crypto.createHash('sha256');
57
- hash.update(fs.readFileSync(filePath));
58
- return hash.digest('hex');
59
- }
60
-
61
- function sha256Buffer(buf) {
62
- return crypto.createHash('sha256').update(buf).digest('hex');
63
- }
64
-
65
- function archiveName() {
66
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
67
- return `construct-backup-${ts}.tar.gz`;
68
- }
69
-
70
- /**
71
- * Create a full backup archive.
72
- *
73
- * @param {object} [opts]
74
- * @param {boolean} [opts.includeSecrets] - include raw secret values in config.env
75
- * @param {string} [opts.registryPath] - path to specialists/registry.json
76
- * @param {string} [opts.destDir] - destination directory (default: ~/.construct/backups)
77
- * @returns {{ path: string, manifest: object }}
78
- */
79
- export async function createBackup({ includeSecrets = false, registryPath, destDir } = {}) {
80
- const dest = destDir || BACKUP_DIR;
81
- fs.mkdirSync(dest, { recursive: true });
82
-
83
- const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'construct-backup-'));
84
- const files = [];
85
- const checksums = {};
86
-
87
- try {
88
- // ── Observations ──────────────────────────────────────────────────────
89
- const obsDir = path.join(CX_DIR, 'observations');
90
- const stagObs = path.join(stageDir, 'observations');
91
- if (fs.existsSync(obsDir)) {
92
- fs.mkdirSync(stagObs, { recursive: true });
93
- for (const f of fs.readdirSync(obsDir).filter((n) => n.endsWith('.json'))) {
94
- const src = path.join(obsDir, f);
95
- const dst = path.join(stagObs, f);
96
- fs.copyFileSync(src, dst);
97
- checksums[`observations/${f}`] = sha256File(dst);
98
- files.push(`observations/${f}`);
99
- }
100
- }
101
-
102
- // ── Sessions ──────────────────────────────────────────────────────────
103
- const sessDir = path.join(CX_DIR, 'sessions');
104
- const stagSess = path.join(stageDir, 'sessions');
105
- if (fs.existsSync(sessDir)) {
106
- fs.mkdirSync(stagSess, { recursive: true });
107
- for (const f of fs.readdirSync(sessDir).filter((n) => n.endsWith('.json'))) {
108
- const src = path.join(sessDir, f);
109
- const dst = path.join(stagSess, f);
110
- fs.copyFileSync(src, dst);
111
- checksums[`sessions/${f}`] = sha256File(dst);
112
- files.push(`sessions/${f}`);
113
- }
114
- }
115
-
116
- // ── config.env ────────────────────────────────────────────────────────
117
- const configEnv = path.join(CONSTRUCT_DIR, 'config.env');
118
- if (fs.existsSync(configEnv)) {
119
- let content = fs.readFileSync(configEnv, 'utf8');
120
- if (!includeSecrets) content = redactConfigEnv(content);
121
- const dst = path.join(stageDir, 'config.env');
122
- fs.writeFileSync(dst, content);
123
- checksums['config.env'] = sha256Buffer(Buffer.from(content));
124
- files.push('config.env');
125
- }
126
-
127
- // ── Registry snapshot ─────────────────────────────────────────────────
128
- const regPath = registryPath || path.join(process.cwd(), 'specialists', 'registry.json');
129
- if (fs.existsSync(regPath)) {
130
- const dst = path.join(stageDir, 'registry.json');
131
- fs.copyFileSync(regPath, dst);
132
- checksums['registry.json'] = sha256File(dst);
133
- files.push('registry.json');
134
- }
135
-
136
- // ── Postgres dump ─────────────────────────────────────────────────────
137
- const pgResult = tryPostgresDump(stageDir);
138
- if (pgResult.success) {
139
- checksums['postgres/dump.sql'] = sha256File(path.join(stageDir, 'postgres', 'dump.sql'));
140
- files.push('postgres/dump.sql');
141
- }
142
-
143
- // ── Manifest ──────────────────────────────────────────────────────────
144
- const manifest = {
145
- version: 1,
146
- createdAt: new Date().toISOString(),
147
- includesSecrets: includeSecrets,
148
- contents: files,
149
- checksums,
150
- };
151
- fs.writeFileSync(path.join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
152
-
153
- // ── Pack to tar.gz ────────────────────────────────────────────────────
154
- const outPath = path.join(dest, archiveName());
155
- execFileSync('tar', ['-czf', outPath, '-C', stageDir, '.'], { stdio: 'pipe' });
156
-
157
- return { path: outPath, manifest };
158
- } finally {
159
- fs.rmSync(stageDir, { recursive: true, force: true });
160
- }
161
- }
162
-
163
- function tryPostgresDump(stageDir) {
164
- const dumpDir = path.join(stageDir, 'postgres');
165
- const dumpFile = path.join(dumpDir, 'dump.sql');
166
-
167
- const dbUrl = process.env.DATABASE_URL || process.env.CONSTRUCT_DATABASE_URL;
168
- if (!dbUrl) return { success: false, reason: 'no DATABASE_URL' };
169
-
170
- try {
171
- fs.mkdirSync(dumpDir, { recursive: true });
172
- execFileSync('pg_dump', [dbUrl, '--no-password', '-f', dumpFile], {
173
- stdio: ['ignore', 'pipe', 'pipe'],
174
- timeout: 60_000,
175
- });
176
- return { success: true };
177
- } catch (err) {
178
- return { success: false, reason: err.message };
179
- }
180
- }
181
-
182
- /**
183
- * Verify a backup archive by comparing stored checksums to actual file hashes.
184
- *
185
- * @param {string} archivePath
186
- * @returns {{ ok: boolean, errors: string[] }}
187
- */
188
- export async function verifyBackup(archivePath) {
189
- const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'construct-verify-'));
190
- const errors = [];
191
- try {
192
- execFileSync('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'pipe' });
193
-
194
- const manifestPath = path.join(extractDir, 'manifest.json');
195
- if (!fs.existsSync(manifestPath)) {
196
- return { ok: false, errors: ['manifest.json missing'] };
197
- }
198
-
199
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
200
- for (const [rel, expected] of Object.entries(manifest.checksums || {})) {
201
- const abs = path.join(extractDir, rel);
202
- if (!fs.existsSync(abs)) {
203
- errors.push(`missing: ${rel}`);
204
- continue;
205
- }
206
- const actual = sha256File(abs);
207
- if (actual !== expected) errors.push(`checksum mismatch: ${rel}`);
208
- }
209
-
210
- return { ok: errors.length === 0, errors };
211
- } finally {
212
- fs.rmSync(extractDir, { recursive: true, force: true });
213
- }
214
- }
215
-
216
- /**
217
- * Restore a backup archive. Prompts for confirmation before writing.
218
- *
219
- * @param {string} archivePath
220
- * @param {object} [opts]
221
- * @param {boolean} [opts.yes] - skip confirmation prompt
222
- * @returns {{ ok: boolean, restored: string[], errors: string[] }}
223
- */
224
- export async function restoreBackup(archivePath, { yes = false } = {}) {
225
- const verify = await verifyBackup(archivePath);
226
- if (!verify.ok) {
227
- return { ok: false, restored: [], errors: [`verification failed: ${verify.errors.join(', ')}`] };
228
- }
229
-
230
- const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'construct-restore-'));
231
- const restored = [];
232
- const errors = [];
233
-
234
- try {
235
- execFileSync('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'pipe' });
236
-
237
- const manifest = JSON.parse(fs.readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
238
-
239
- // Restore observations
240
- const stagObs = path.join(extractDir, 'observations');
241
- if (fs.existsSync(stagObs)) {
242
- const dest = path.join(CX_DIR, 'observations');
243
- fs.mkdirSync(dest, { recursive: true });
244
- for (const f of fs.readdirSync(stagObs)) {
245
- fs.copyFileSync(path.join(stagObs, f), path.join(dest, f));
246
- restored.push(`observations/${f}`);
247
- }
248
- }
249
-
250
- // Restore sessions
251
- const stagSess = path.join(extractDir, 'sessions');
252
- if (fs.existsSync(stagSess)) {
253
- const dest = path.join(CX_DIR, 'sessions');
254
- fs.mkdirSync(dest, { recursive: true });
255
- for (const f of fs.readdirSync(stagSess)) {
256
- fs.copyFileSync(path.join(stagSess, f), path.join(dest, f));
257
- restored.push(`sessions/${f}`);
258
- }
259
- }
260
-
261
- // Restore config.env — only if it contained no redacted lines
262
- const stagConfig = path.join(extractDir, 'config.env');
263
- if (fs.existsSync(stagConfig)) {
264
- const content = fs.readFileSync(stagConfig, 'utf8');
265
- if (!content.includes('<redacted>')) {
266
- fs.mkdirSync(CONSTRUCT_DIR, { recursive: true });
267
- fs.writeFileSync(path.join(CONSTRUCT_DIR, 'config.env'), content, { mode: 0o600 });
268
- restored.push('config.env');
269
- } else {
270
- errors.push('config.env has redacted secrets — restore credentials manually');
271
- }
272
- }
273
-
274
- // Restore registry.json
275
- const stagReg = path.join(extractDir, 'registry.json');
276
- if (fs.existsSync(stagReg)) {
277
- const dest = path.join(process.cwd(), 'specialists', 'registry.json');
278
- fs.mkdirSync(path.dirname(dest), { recursive: true });
279
- fs.copyFileSync(stagReg, dest);
280
- restored.push('registry.json');
281
- }
282
-
283
- // Restore Postgres (last — hardest to undo)
284
- const stagPg = path.join(extractDir, 'postgres', 'dump.sql');
285
- if (fs.existsSync(stagPg)) {
286
- const pgResult = tryPostgresRestore(stagPg);
287
- if (pgResult.success) {
288
- restored.push('postgres');
289
- } else {
290
- errors.push(`postgres restore failed: ${pgResult.reason}`);
291
- }
292
- }
293
-
294
- return { ok: errors.length === 0, restored, errors };
295
- } finally {
296
- fs.rmSync(extractDir, { recursive: true, force: true });
297
- }
298
- }
299
-
300
- function tryPostgresRestore(dumpFile) {
301
- const dbUrl = process.env.DATABASE_URL || process.env.CONSTRUCT_DATABASE_URL;
302
- if (!dbUrl) return { success: false, reason: 'no DATABASE_URL' };
303
- try {
304
- execFileSync('psql', [dbUrl, '-f', dumpFile], {
305
- stdio: ['ignore', 'pipe', 'pipe'],
306
- timeout: 120_000,
307
- });
308
- return { success: true };
309
- } catch (err) {
310
- return { success: false, reason: err.message };
311
- }
312
- }
313
-
314
- /**
315
- * List backup archives in the default backup directory.
316
- */
317
- export function listBackups(dir = BACKUP_DIR) {
318
- if (!fs.existsSync(dir)) return [];
319
- return fs.readdirSync(dir)
320
- .filter((f) => f.startsWith('construct-backup-') && f.endsWith('.tar.gz'))
321
- .sort()
322
- .reverse()
323
- .map((f) => {
324
- const fullPath = path.join(dir, f);
325
- const stat = fs.statSync(fullPath);
326
- return { name: f, path: fullPath, size: stat.size, mtime: stat.mtime };
327
- });
328
- }
329
-
330
- // Keep the `keep` most recent backups in `dir`; unlink the rest. The return
331
- // value names each pruned file by archive basename. Per-file unlink errors
332
- // are swallowed so one locked archive can't abort the whole pass.
333
- export function pruneBackups({ keep = 10, dir = BACKUP_DIR } = {}) {
334
- const list = listBackups(dir);
335
- if (list.length <= keep) return { kept: list.length, removed: [] };
336
- const toRemove = list.slice(keep);
337
- const removed = [];
338
- for (const b of toRemove) {
339
- try {
340
- fs.unlinkSync(b.path);
341
- removed.push(b.name);
342
- } catch {
343
- /* best effort — leave file in place if it's locked or unreadable */
344
- }
345
- }
346
- return { kept: keep, removed };
347
- }