@geraldmaron/construct 1.0.23 → 1.1.0

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 (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
@@ -1,14 +0,0 @@
1
- -- db/schema/003_observation_reconciliation.sql
2
- -- Stamp each observation with the content hash and embedding model that
3
- -- produced its vector. An idempotent reconciliation pass uses (content_hash,
4
- -- model) to find rows whose embedding is missing or stale — content edited, or
5
- -- the embedding model changed — and re-embed only those, rather than relying on
6
- -- the inline write at creation time always succeeding. Mirrors the
7
- -- (content_hash, model) tracking construct_embeddings already carries for
8
- -- documents. Fully idempotent: safe to re-apply.
9
-
10
- ALTER TABLE construct_observations ADD COLUMN IF NOT EXISTS content_hash text;
11
- ALTER TABLE construct_observations ADD COLUMN IF NOT EXISTS model text;
12
-
13
- CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON construct_observations (content_hash);
14
- CREATE INDEX IF NOT EXISTS idx_observations_model ON construct_observations (model);
@@ -1,46 +0,0 @@
1
- -- 004_recommendations.sql — Recommendations table for team and enterprise mode.
2
- -- Solo mode uses the JSONL store under .cx/intake/ instead.
3
- --
4
- -- Dedup key is enforced at the (project, dedup_key) level — matches the in-memory
5
- -- dedup contract in lib/embed/recommendation-store.mjs.
6
- -- Active recommendations: dismissed_at IS NULL AND superseded_at IS NULL.
7
-
8
- create table if not exists construct_recommendations (
9
- id text primary key,
10
- project text not null,
11
- dedup_key text not null,
12
- type text not null,
13
- title text not null,
14
- reason text,
15
- lane text,
16
- signal_count int not null default 1,
17
- total_signal_count int not null default 1,
18
- customer_impact int not null default 0,
19
- recency_bonus int not null default 0,
20
- strategic_bonus int not null default 0,
21
- score float not null default 0,
22
- priority text not null default 'P3',
23
- source_signal_ids jsonb not null default '[]'::jsonb,
24
- first_seen timestamptz not null default now(),
25
- last_seen timestamptz not null default now(),
26
- dismissed_at timestamptz,
27
- dismiss_reason text,
28
- superseded_at timestamptz,
29
- superseded_by_id text,
30
- suppressed_until timestamptz,
31
- suppress_reason text,
32
- updated_at timestamptz not null default now()
33
- );
34
-
35
- create unique index if not exists construct_recommendations_project_dedup_key_idx
36
- on construct_recommendations (project, dedup_key);
37
-
38
- create index if not exists construct_recommendations_priority_score_idx
39
- on construct_recommendations (project, priority, score desc);
40
-
41
- create index if not exists construct_recommendations_last_seen_idx
42
- on construct_recommendations (project, last_seen desc);
43
-
44
- create index if not exists construct_recommendations_active_idx
45
- on construct_recommendations (project, dismissed_at)
46
- where dismissed_at is null;
@@ -1,21 +0,0 @@
1
- -- 005_strategy.sql — Product strategy store for team and enterprise mode.
2
- -- Solo mode uses ~/.cx/strategy.md instead.
3
- --
4
- -- Each row is an immutable version snapshot. The latest version for a project
5
- -- is the row with the highest version number (or max updated_at when equal).
6
- -- Callers insert a new row on each write; old versions are retained for history.
7
-
8
- create table if not exists construct_strategy (
9
- id bigserial primary key,
10
- project text not null,
11
- content text not null,
12
- version int not null default 1,
13
- updated_at timestamptz not null default now(),
14
- updated_by text
15
- );
16
-
17
- create unique index if not exists construct_strategy_project_version_idx
18
- on construct_strategy (project, version);
19
-
20
- create index if not exists construct_strategy_project_updated_at_idx
21
- on construct_strategy (project, updated_at desc);
@@ -1,24 +0,0 @@
1
- -- 006_graph.sql. GraphRAG community columns for entities.
2
- --
3
- -- Phase C9 foundations. Adds community_id (label propagation output) and a
4
- -- community summary table so the Pg-backed deployment can query communities
5
- -- without a JSONL scan. Solo-mode JSONL remains the source of truth; this
6
- -- table is the projection.
7
-
8
- ALTER TABLE construct_entities
9
- ADD COLUMN IF NOT EXISTS community_id text,
10
- ADD COLUMN IF NOT EXISTS community_size int;
11
-
12
- CREATE INDEX IF NOT EXISTS idx_entities_community ON construct_entities(project, community_id);
13
-
14
- CREATE TABLE IF NOT EXISTS construct_entity_communities (
15
- community_id text NOT NULL,
16
- project text NOT NULL,
17
- size int NOT NULL DEFAULT 0,
18
- top_members jsonb DEFAULT '[]'::jsonb,
19
- summary text,
20
- updated_at timestamptz NOT NULL DEFAULT now(),
21
- PRIMARY KEY (project, community_id)
22
- );
23
-
24
- CREATE INDEX IF NOT EXISTS idx_communities_size ON construct_entity_communities(project, size DESC);
@@ -1,30 +0,0 @@
1
- -- Tag attribution and migration tracking for construct_documents.
2
- -- The construct_documents.tags JSONB column already exists in 001_init.sql.
3
- -- This migration adds GIN indexing, attribution history, and migration audit.
4
-
5
- create index if not exists construct_documents_tags_gin_idx
6
- on construct_documents using gin (tags jsonb_path_ops);
7
-
8
- create table if not exists construct_tag_attribution (
9
- document_id text not null references construct_documents(id) on delete cascade,
10
- tag text not null,
11
- source text not null, -- 'agent:classifier' | 'user' | 'curator'
12
- confidence numeric,
13
- applied_at timestamptz not null default now(),
14
- migrated_from text,
15
- primary key (document_id, tag, source)
16
- );
17
- create index if not exists construct_tag_attribution_tag_idx on construct_tag_attribution (tag);
18
-
19
- create table if not exists construct_tag_migrations (
20
- id text primary key,
21
- from_tag text not null,
22
- to_tag text,
23
- executed_at timestamptz not null default now(),
24
- executed_by text not null,
25
- reason text not null,
26
- doc_count_before integer not null,
27
- doc_count_after integer,
28
- reversible boolean not null default true,
29
- rolled_back_at timestamptz
30
- );
@@ -1,24 +0,0 @@
1
- -- db/schema/008_skill_usage.sql — Per-skill invocation log.
2
- --
3
- -- Backs `construct skills usage/orphans/hot`. The local
4
- -- JSONL path (~/.cx/skill-calls.jsonl) is primary in solo mode; this table is
5
- -- primary in team/enterprise mode. One-time backfill via
6
- -- `construct skills backfill-postgres`.
7
-
8
- create table if not exists construct_skill_invocations (
9
- id bigserial primary key,
10
- ts timestamptz not null,
11
- skill_id text not null,
12
- source text not null, -- 'mcp' | 'role-preload' | 'prompt-composer' | …
13
- agent_id text,
14
- session_id text,
15
- caller_context text,
16
- latency_ms integer,
17
- tokens_returned integer
18
- );
19
-
20
- create index if not exists construct_skill_invocations_skill_ts_idx
21
- on construct_skill_invocations (skill_id, ts desc);
22
-
23
- create index if not exists construct_skill_invocations_session_idx
24
- on construct_skill_invocations (session_id);
@@ -1,14 +0,0 @@
1
- -- Scheduler job state for team and enterprise deployment modes.
2
- -- Solo mode uses native trigger files instead of this table.
3
- -- The lock_holder column stores a short identifier for the process/host
4
- -- that currently holds the advisory lock for a running job.
5
-
6
- create table if not exists construct_scheduled_jobs (
7
- id text primary key,
8
- schedule text not null,
9
- last_run_at timestamptz,
10
- last_run_status text,
11
- lock_holder text,
12
- lock_acquired_at timestamptz,
13
- created_at timestamptz not null default now()
14
- );
@@ -1,51 +0,0 @@
1
- -- db/schema/010_cx_scores.sql — Per-trace quality score log + skill quality correlation view.
2
- --
3
- -- Backs `construct skills correlate-quality`. Producer: lib/mcp/tools/telemetry.mjs
4
- -- cxScore() writes a row here when CONSTRUCT_DB_URL (or DATABASE_URL) is set.
5
- -- Local JSONL/observation paths remain primary in solo mode; this table is
6
- -- primary in team/enterprise mode where multiple agents share a session
7
- -- timeline and the dashboard reads aggregate correlations.
8
-
9
- create table if not exists construct_cx_scores (
10
- id bigserial primary key,
11
- ts timestamptz not null,
12
- trace_id text not null,
13
- session_id text,
14
- agent_id text,
15
- name text not null default 'quality',
16
- value numeric not null,
17
- comment text
18
- );
19
-
20
- create index if not exists construct_cx_scores_trace_idx
21
- on construct_cx_scores (trace_id);
22
-
23
- create index if not exists construct_cx_scores_session_ts_idx
24
- on construct_cx_scores (session_id, ts desc);
25
-
26
- create index if not exists construct_cx_scores_agent_ts_idx
27
- on construct_cx_scores (agent_id, ts desc);
28
-
29
- -- Skill quality correlation view: joins skill invocations and quality
30
- -- scores by session_id so a reader can ask "for sessions where skill X was
31
- -- invoked, what's the median / mean / p10 quality score?" The view is a
32
- -- simple aggregate over the past 90 days; queries that need a longer
33
- -- window can pull straight from the underlying tables. Materialised view
34
- -- not used here because the correlation surface is small and the freshness
35
- -- expectation is "current session" not "yesterday's batch."
36
-
37
- create or replace view construct_skill_quality_correlation as
38
- select
39
- inv.skill_id,
40
- count(distinct inv.session_id) as sessions,
41
- count(distinct sc.id) as score_count,
42
- round(avg(sc.value)::numeric, 3) as mean_score,
43
- round((percentile_cont(0.5) within group (order by sc.value))::numeric, 3) as median_score,
44
- round((percentile_cont(0.10) within group (order by sc.value))::numeric, 3) as p10_score,
45
- round((percentile_cont(0.90) within group (order by sc.value))::numeric, 3) as p90_score,
46
- max(sc.ts) as last_scored_at
47
- from construct_skill_invocations inv
48
- inner join construct_cx_scores sc on sc.session_id = inv.session_id
49
- where inv.ts > now() - interval '90 days'
50
- and sc.ts > now() - interval '90 days'
51
- group by inv.skill_id;
@@ -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,102 +0,0 @@
1
- /**
2
- * lib/reconcile/postgres-namespace.mjs — migrate a local Postgres compose that
3
- * predates per-home identifier derivation (ADR-0027 / construct-lb7b).
4
- *
5
- * Earlier versions wrote a singular `construct-postgres` container on the fixed
6
- * 54329 port. Two isolated HOMEs sharing that container clobber each other's
7
- * data and fight over the port. The derivation namespaces both per home; the
8
- * repair rewrites a legacy compose file to the namespaced form.
9
- *
10
- * apply() is strictly data-safe: it only rewrites the compose via
11
- * writeLocalPostgresCompose(home). It runs no docker command, removes no
12
- * container, and deletes no volume — the existing volume and any data inside it
13
- * are left intact. The returned summary instructs the operator to run
14
- * `construct down && construct up` to recreate the container against the
15
- * namespaced compose.
16
- *
17
- * Safety: `ask`. The rewrite changes the container/port a running stack binds
18
- * to, so it runs only on explicit consent. detect() reads only; apply() is
19
- * idempotent because the namespaced compose fails to match the legacy markers.
20
- */
21
-
22
- import fs from 'node:fs';
23
-
24
- import { homeDir } from '../paths.mjs';
25
- import {
26
- LEGACY_PG_CONTAINER,
27
- LEGACY_PG_PORT,
28
- postgresContainerName,
29
- postgresPort,
30
- } from '../home-namespace.mjs';
31
- import { localPostgresComposePath, writeLocalPostgresCompose } from '../setup.mjs';
32
-
33
- // The container marker anchors to end-of-line so the namespaced form
34
- // `construct-postgres-<suffix>` does not match the singular legacy name as a
35
- // prefix (which would defeat idempotency).
36
-
37
- const LEGACY_CONTAINER_RE = new RegExp(`container_name:\\s*${LEGACY_PG_CONTAINER}\\s*$`, 'm');
38
- const LEGACY_PORT_RE = new RegExp(`:${LEGACY_PG_PORT}:`);
39
-
40
- function legacyMarkers(content, home) {
41
- const derivedContainer = postgresContainerName(process.env, home);
42
- const derivedPort = postgresPort(process.env, home);
43
-
44
- // The legacy singular container only counts when the derivation would name it
45
- // differently; an explicit CONSTRUCT_PG_CONTAINER override pinned to the
46
- // singular name is the intended state, not drift.
47
-
48
- const hasLegacyContainer = LEGACY_CONTAINER_RE.test(content) && derivedContainer !== LEGACY_PG_CONTAINER;
49
- const hasLegacyPort = LEGACY_PORT_RE.test(content) && derivedPort !== LEGACY_PG_PORT;
50
- return { hasLegacyContainer, hasLegacyPort, derivedContainer, derivedPort };
51
- }
52
-
53
- async function detect() {
54
- const home = homeDir();
55
- const composePath = localPostgresComposePath(home);
56
- if (!fs.existsSync(composePath)) {
57
- return { needsRepair: false, summary: 'No local Postgres compose file present.' };
58
- }
59
- let content = '';
60
- try {
61
- content = fs.readFileSync(composePath, 'utf8');
62
- } catch (err) {
63
- return { needsRepair: false, summary: `Could not read compose file: ${err.message}` };
64
- }
65
- const markers = legacyMarkers(content, home);
66
- if (!markers.hasLegacyContainer && !markers.hasLegacyPort) {
67
- return { needsRepair: false, summary: 'Local Postgres compose already uses per-home identifiers.' };
68
- }
69
- const reasons = [];
70
- if (markers.hasLegacyContainer) reasons.push(`container ${LEGACY_PG_CONTAINER} → ${markers.derivedContainer}`);
71
- if (markers.hasLegacyPort) reasons.push(`port ${LEGACY_PG_PORT} → ${markers.derivedPort}`);
72
- return {
73
- needsRepair: true,
74
- summary: `Local Postgres compose predates per-home derivation (${reasons.join(', ')}).`,
75
- details: { composePath, ...markers },
76
- };
77
- }
78
-
79
- async function apply() {
80
- const home = homeDir();
81
- const composePath = localPostgresComposePath(home);
82
- if (!fs.existsSync(composePath)) return { summary: 'No compose file to migrate.' };
83
-
84
- // Rewrite the compose to the per-home form only. No docker invocation, no
85
- // container removal, no volume deletion — the operator recreates the
86
- // container themselves so data handling stays under their control.
87
-
88
- writeLocalPostgresCompose(home);
89
- const container = postgresContainerName(process.env, home);
90
- const port = postgresPort(process.env, home);
91
- return {
92
- summary: `Rewrote local Postgres compose to per-home identifiers (container ${container}, port ${port}). Run \`construct down && construct up\` to recreate the container; existing data and volume are untouched.`,
93
- };
94
- }
95
-
96
- export default {
97
- id: 'postgres-namespace',
98
- description: 'Rewrite a legacy local Postgres compose to per-home container/port identifiers (data-safe).',
99
- safety: 'ask',
100
- detect,
101
- apply,
102
- };
@@ -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';