@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.
- package/README.md +23 -7
- package/bin/construct +206 -228
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +31 -10
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +39 -163
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +72 -10
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +169 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +59 -252
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +104 -480
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -43
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +7 -6
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -11
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +427 -107
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +3 -12
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +28 -36
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +15 -15
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/server/static/index.html +0 -1
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/vector-store.mjs +0 -100
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/legacy-skills-cleanup.mjs — clean up SKILL.md files at
|
|
3
|
+
* ~/.agents/skills/ whose frontmatter is missing the `name` and
|
|
4
|
+
* `description` fields required by the Anthropic Agent Skills loader.
|
|
5
|
+
*
|
|
6
|
+
* The cleanup is constrained to files whose frontmatter matches the
|
|
7
|
+
* Construct doc-stamp shape (cx_doc_id + generator: construct/sync-specialists
|
|
8
|
+
* + body_hash), so non-Construct skill authors in the same tree are
|
|
9
|
+
* untouched. Empty parent directories prune upward; ~/.agents itself is
|
|
10
|
+
* removed only if Construct emptied it.
|
|
11
|
+
*
|
|
12
|
+
* Safety: `auto`. detect() is pure-read; apply() never modifies files
|
|
13
|
+
* outside the matched-stamp set and never touches files whose mtime falls
|
|
14
|
+
* after the doc-stamp timestamp recorded in the frontmatter (a signal
|
|
15
|
+
* that something other than Construct's sync may have edited the file).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { homeDir } from '../paths.mjs';
|
|
22
|
+
|
|
23
|
+
const LEGACY_REL = path.join('.agents', 'skills');
|
|
24
|
+
|
|
25
|
+
function legacyRoot() {
|
|
26
|
+
return path.join(homeDir(), LEGACY_REL);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STAMP_RE = /^cx_doc_id:\s*\S+/m;
|
|
30
|
+
const GENERATOR_RE = /^generator:\s*construct\/sync-specialists/m;
|
|
31
|
+
const BODY_HASH_RE = /^body_hash:\s*sha256:/m;
|
|
32
|
+
const NAME_RE = /^name:\s*\S+/m;
|
|
33
|
+
const DESCRIPTION_RE = /^description:\s*\S/m;
|
|
34
|
+
const UPDATED_AT_RE = /^updated_at:\s*(\S+)/m;
|
|
35
|
+
|
|
36
|
+
// Frontmatter shapes the Construct sync writer has emitted. The "stale"
|
|
37
|
+
// shape is doc-stamp-only (cx_doc_id + generator + body_hash, no name, no
|
|
38
|
+
// description) — the Anthropic Agent Skills loader rejects it. The current
|
|
39
|
+
// shape carries name + description and parses cleanly.
|
|
40
|
+
|
|
41
|
+
function classifyFrontmatter(content) {
|
|
42
|
+
if (!content || !content.startsWith('---\n')) return { kind: 'no-frontmatter' };
|
|
43
|
+
const closeIdx = content.indexOf('\n---', 4);
|
|
44
|
+
if (closeIdx === -1) return { kind: 'malformed' };
|
|
45
|
+
const fm = content.slice(4, closeIdx);
|
|
46
|
+
const isConstructStamp = STAMP_RE.test(fm) && GENERATOR_RE.test(fm) && BODY_HASH_RE.test(fm);
|
|
47
|
+
const hasName = NAME_RE.test(fm);
|
|
48
|
+
const hasDescription = DESCRIPTION_RE.test(fm);
|
|
49
|
+
const updatedMatch = fm.match(UPDATED_AT_RE);
|
|
50
|
+
const updatedAtMs = updatedMatch ? Date.parse(updatedMatch[1]) : NaN;
|
|
51
|
+
if (isConstructStamp && !hasName && !hasDescription) {
|
|
52
|
+
return { kind: 'construct-stale', updatedAtMs };
|
|
53
|
+
}
|
|
54
|
+
if (isConstructStamp && hasName && hasDescription) {
|
|
55
|
+
return { kind: 'construct-current', updatedAtMs };
|
|
56
|
+
}
|
|
57
|
+
return { kind: 'foreign' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function walkSkillFiles(dir) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let entries;
|
|
63
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
64
|
+
catch { return out; }
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const full = path.join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) out.push(...walkSkillFiles(full));
|
|
68
|
+
else if (entry.isFile() && entry.name === 'SKILL.md') out.push(full);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inspectFile(file) {
|
|
74
|
+
let content = '';
|
|
75
|
+
let stat;
|
|
76
|
+
try {
|
|
77
|
+
content = fs.readFileSync(file, 'utf8');
|
|
78
|
+
stat = fs.statSync(file);
|
|
79
|
+
} catch {
|
|
80
|
+
return { file, classification: { kind: 'unreadable' } };
|
|
81
|
+
}
|
|
82
|
+
const classification = classifyFrontmatter(content);
|
|
83
|
+
return { file, classification, mtimeMs: stat.mtimeMs };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A file is safe to remove when (a) it matches the Construct stale shape,
|
|
87
|
+
// (b) its mtime is within a small skew of its declared updated_at — if
|
|
88
|
+
// the user edited it after Construct wrote it, mtime drifts ahead, and
|
|
89
|
+
// the file is preserved.
|
|
90
|
+
|
|
91
|
+
const USER_EDIT_SKEW_MS = 5 * 60 * 1000;
|
|
92
|
+
|
|
93
|
+
function isSafeToRemove(inspection) {
|
|
94
|
+
const c = inspection.classification;
|
|
95
|
+
if (c.kind !== 'construct-stale') return false;
|
|
96
|
+
if (!Number.isFinite(c.updatedAtMs)) return true;
|
|
97
|
+
const drift = inspection.mtimeMs - c.updatedAtMs;
|
|
98
|
+
return drift <= USER_EDIT_SKEW_MS;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pruneEmptyDirsUpward(startDir, stopDir) {
|
|
102
|
+
let cur = startDir;
|
|
103
|
+
while (cur && cur.startsWith(stopDir) && cur !== stopDir) {
|
|
104
|
+
let entries;
|
|
105
|
+
try { entries = fs.readdirSync(cur); } catch { return; }
|
|
106
|
+
if (entries.length > 0) return;
|
|
107
|
+
try { fs.rmdirSync(cur); } catch { return; }
|
|
108
|
+
cur = path.dirname(cur);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function detect() {
|
|
113
|
+
const root = legacyRoot();
|
|
114
|
+
if (!fs.existsSync(root)) {
|
|
115
|
+
return { needsRepair: false, summary: 'No legacy ~/.agents/skills directory present.' };
|
|
116
|
+
}
|
|
117
|
+
const files = walkSkillFiles(root);
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
return { needsRepair: false, summary: 'Legacy directory present but empty.' };
|
|
120
|
+
}
|
|
121
|
+
let stale = 0;
|
|
122
|
+
let staleUserEdited = 0;
|
|
123
|
+
let constructCurrent = 0;
|
|
124
|
+
let foreign = 0;
|
|
125
|
+
let unreadable = 0;
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const inspection = inspectFile(file);
|
|
128
|
+
const kind = inspection.classification.kind;
|
|
129
|
+
if (kind === 'construct-stale') {
|
|
130
|
+
if (isSafeToRemove(inspection)) stale += 1;
|
|
131
|
+
else staleUserEdited += 1;
|
|
132
|
+
} else if (kind === 'construct-current') {
|
|
133
|
+
constructCurrent += 1;
|
|
134
|
+
} else if (kind === 'foreign') {
|
|
135
|
+
foreign += 1;
|
|
136
|
+
} else {
|
|
137
|
+
unreadable += 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (stale === 0) {
|
|
141
|
+
return {
|
|
142
|
+
needsRepair: false,
|
|
143
|
+
summary: `Legacy directory present, no Construct-shaped stale files to remove (current: ${constructCurrent}, foreign: ${foreign}, user-edited stale: ${staleUserEdited}).`,
|
|
144
|
+
details: { stale, staleUserEdited, constructCurrent, foreign, unreadable, total: files.length },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
needsRepair: true,
|
|
149
|
+
summary: `${stale} stale Construct SKILL.md files at ~/.agents/skills (current: ${constructCurrent}, foreign: ${foreign}, user-edited stale: ${staleUserEdited}, unreadable: ${unreadable}).`,
|
|
150
|
+
details: { stale, staleUserEdited, constructCurrent, foreign, unreadable, total: files.length },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function apply() {
|
|
155
|
+
const root = legacyRoot();
|
|
156
|
+
if (!fs.existsSync(root)) return { summary: 'Already clean.' };
|
|
157
|
+
const files = walkSkillFiles(root);
|
|
158
|
+
let removed = 0;
|
|
159
|
+
let preserved = 0;
|
|
160
|
+
const dirsToCheck = new Set();
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
const inspection = inspectFile(file);
|
|
163
|
+
if (!isSafeToRemove(inspection)) {
|
|
164
|
+
preserved += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
fs.unlinkSync(file);
|
|
169
|
+
removed += 1;
|
|
170
|
+
dirsToCheck.add(path.dirname(file));
|
|
171
|
+
} catch {
|
|
172
|
+
preserved += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const dir of [...dirsToCheck].sort((a, b) => b.length - a.length)) {
|
|
176
|
+
pruneEmptyDirsUpward(dir, root);
|
|
177
|
+
}
|
|
178
|
+
// If the root is now empty, prune it and the .agents parent if also empty.
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (fs.readdirSync(root).length === 0) {
|
|
182
|
+
fs.rmdirSync(root);
|
|
183
|
+
const parent = path.dirname(root);
|
|
184
|
+
try {
|
|
185
|
+
if (fs.readdirSync(parent).length === 0) fs.rmdirSync(parent);
|
|
186
|
+
} catch { /* parent has unrelated content */ }
|
|
187
|
+
}
|
|
188
|
+
} catch { /* root has remaining entries */ }
|
|
189
|
+
return {
|
|
190
|
+
summary: `Removed ${removed} stale SKILL.md file${removed === 1 ? '' : 's'} from ~/.agents/skills${preserved > 0 ? ` (preserved ${preserved} with user-edit signal)` : ''}.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default {
|
|
195
|
+
id: 'legacy-skills-cleanup',
|
|
196
|
+
description: 'Remove SKILL.md files at ~/.agents/skills whose frontmatter lacks the required name/description fields.',
|
|
197
|
+
safety: 'auto',
|
|
198
|
+
detect,
|
|
199
|
+
apply,
|
|
200
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/mcp-entry-reconcile.mjs — strip Codex `[mcp_servers.<id>]`
|
|
3
|
+
* tables whose `bearer_token_env_var` references an unset env var from configs
|
|
4
|
+
* written before the sync-time fix (ADR-0027 / construct-n6h7).
|
|
5
|
+
*
|
|
6
|
+
* Codex aborts at startup when an MCP server's bearer_token_env_var names an
|
|
7
|
+
* env var that is unset. The sync writer now omits such entries, but a config
|
|
8
|
+
* written by an earlier version still carries them. This repair removes exactly
|
|
9
|
+
* those construct-managed tables from the global `~/.codex/config.toml` and a
|
|
10
|
+
* project `.codex/config.toml` when present, preserving every other table.
|
|
11
|
+
*
|
|
12
|
+
* Detection mirrors the sync-time gate (codexMcpEnvResolves): an entry whose
|
|
13
|
+
* token env var resolves is kept; only unresolved-credential tables are pruned.
|
|
14
|
+
* Tables with no credential requirement are never touched. Removal is scoped to
|
|
15
|
+
* registry-managed MCP ids by exact table name, so user-authored mcp_servers
|
|
16
|
+
* tables are out of scope. Safety: `auto`. detect() reads only; apply() is
|
|
17
|
+
* idempotent because removeTomlTables drains the matched tables to none.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
import { homeDir } from '../paths.mjs';
|
|
25
|
+
import {
|
|
26
|
+
getCodexConfigPath,
|
|
27
|
+
buildCodexMcpEntry,
|
|
28
|
+
removeTomlTables,
|
|
29
|
+
tomlString,
|
|
30
|
+
} from '../codex-config.mjs';
|
|
31
|
+
|
|
32
|
+
function escapeRegExp(value) {
|
|
33
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function registryMcpServers() {
|
|
37
|
+
const installed = path.join(homeDir(), '.construct', 'specialists', 'registry.json');
|
|
38
|
+
const bundled = fileURLToPath(new URL('../../specialists/registry.json', import.meta.url));
|
|
39
|
+
for (const src of [installed, bundled]) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(src, 'utf8'));
|
|
42
|
+
if (parsed && parsed.mcpServers) return parsed.mcpServers;
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasMcpTable(text, id) {
|
|
51
|
+
return new RegExp(`^\\[mcp_servers\\.(?:${escapeRegExp(id)}|${escapeRegExp(tomlString(id))})\\]`, 'm').test(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An entry with no bearer_token_env_var has no credential requirement and is
|
|
55
|
+
// always valid. An entry whose token env var is set resolves; an unset value
|
|
56
|
+
// (undefined or empty string) is the abort condition Codex hits at startup.
|
|
57
|
+
|
|
58
|
+
function tokenResolves(id, def, env) {
|
|
59
|
+
const entry = buildCodexMcpEntry(id, def, env);
|
|
60
|
+
const tokenVar = entry?.bearer_token_env_var;
|
|
61
|
+
if (!tokenVar) return true;
|
|
62
|
+
const val = env[tokenVar];
|
|
63
|
+
return val !== undefined && val !== '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function configTargets() {
|
|
67
|
+
const targets = [{ scope: 'global', file: getCodexConfigPath(homeDir()) }];
|
|
68
|
+
const projectConfig = path.join(process.cwd(), '.codex', 'config.toml');
|
|
69
|
+
if (fs.existsSync(projectConfig)) targets.push({ scope: 'project', file: projectConfig });
|
|
70
|
+
return targets;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function unresolvedTables(text, registryMcp, env) {
|
|
74
|
+
const ids = [];
|
|
75
|
+
for (const id of Object.keys(registryMcp)) {
|
|
76
|
+
if (!hasMcpTable(text, id)) continue;
|
|
77
|
+
if (tokenResolves(id, registryMcp[id], env)) continue;
|
|
78
|
+
ids.push(id);
|
|
79
|
+
}
|
|
80
|
+
return ids;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function detect() {
|
|
84
|
+
const registryMcp = registryMcpServers();
|
|
85
|
+
if (Object.keys(registryMcp).length === 0) {
|
|
86
|
+
return { needsRepair: false, summary: 'No registry MCP servers to reconcile.' };
|
|
87
|
+
}
|
|
88
|
+
const env = process.env;
|
|
89
|
+
const offenders = [];
|
|
90
|
+
for (const target of configTargets()) {
|
|
91
|
+
let text = '';
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.existsSync(target.file)) continue;
|
|
94
|
+
text = fs.readFileSync(target.file, 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const ids = unresolvedTables(text, registryMcp, env);
|
|
99
|
+
if (ids.length > 0) offenders.push({ ...target, ids });
|
|
100
|
+
}
|
|
101
|
+
if (offenders.length === 0) {
|
|
102
|
+
return { needsRepair: false, summary: 'No Codex MCP tables reference an unset token env var.' };
|
|
103
|
+
}
|
|
104
|
+
const total = offenders.reduce((acc, o) => acc + o.ids.length, 0);
|
|
105
|
+
return {
|
|
106
|
+
needsRepair: true,
|
|
107
|
+
summary: `${total} Codex MCP table${total === 1 ? '' : 's'} reference an unset token env var (${offenders.map((o) => `${o.scope}: ${o.ids.join(', ')}`).join('; ')}).`,
|
|
108
|
+
details: { offenders: offenders.map((o) => ({ scope: o.scope, ids: o.ids })) },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function apply() {
|
|
113
|
+
const registryMcp = registryMcpServers();
|
|
114
|
+
const env = process.env;
|
|
115
|
+
let removed = 0;
|
|
116
|
+
const scopes = [];
|
|
117
|
+
for (const target of configTargets()) {
|
|
118
|
+
if (!fs.existsSync(target.file)) continue;
|
|
119
|
+
const text = fs.readFileSync(target.file, 'utf8');
|
|
120
|
+
const ids = unresolvedTables(text, registryMcp, env);
|
|
121
|
+
if (ids.length === 0) continue;
|
|
122
|
+
|
|
123
|
+
// Strip both the bare and quoted table forms per id, matching the names
|
|
124
|
+
// the sync writer emits, so a table written either way is removed.
|
|
125
|
+
|
|
126
|
+
const tableNames = ids.flatMap((id) => [`mcp_servers.${id}`, `mcp_servers.${tomlString(id)}`]);
|
|
127
|
+
const cleaned = removeTomlTables(text, tableNames);
|
|
128
|
+
fs.writeFileSync(target.file, `${cleaned.trimEnd()}\n`, 'utf8');
|
|
129
|
+
removed += ids.length;
|
|
130
|
+
scopes.push(`${target.scope}: ${ids.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
if (removed === 0) return { summary: 'Already clean.' };
|
|
133
|
+
return { summary: `Removed ${removed} unresolved Codex MCP table${removed === 1 ? '' : 's'} (${scopes.join('; ')}).` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default {
|
|
137
|
+
id: 'mcp-entry-reconcile',
|
|
138
|
+
description: 'Strip Codex MCP tables whose bearer_token_env_var references an unset env var from existing configs.',
|
|
139
|
+
safety: 'auto',
|
|
140
|
+
detect,
|
|
141
|
+
apply,
|
|
142
|
+
};
|
|
@@ -37,12 +37,32 @@ function which(bin) {
|
|
|
37
37
|
return result.stdout.trim().split('\n')[0] || null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// First-run docling provisioning blocks for minutes (uv install, venv, then a
|
|
41
|
+
// large `uv pip install docling` that pulls ML deps). Without a heartbeat it
|
|
42
|
+
// reads as a hang. Progress goes to stderr only, so JSON consumers on stdout are
|
|
43
|
+
// unaffected; it is the long-operation's own channel, not a suppressible notice.
|
|
44
|
+
|
|
45
|
+
function progress(message) {
|
|
46
|
+
try { process.stderr.write(`[docling setup] ${message}\n`); } catch { /* stderr closed */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// spawnSync sets status=null and error.code=ETIMEDOUT on timeout; distinguish a
|
|
50
|
+
// timeout from a real non-zero exit so the message tells the user what to do.
|
|
51
|
+
|
|
52
|
+
function describeStepFailure(label, result, timeoutMs) {
|
|
53
|
+
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
|
|
54
|
+
return `${label} timed out after ${Math.round(timeoutMs / 1000)}s — docling's one-time download did not complete. Check network/proxy and re-run; or set ingest.fallback to "adapter" to skip docling.`;
|
|
55
|
+
}
|
|
56
|
+
return `${label} failed (${result.status}): ${result.stderr || result.stdout || result.error?.message || 'unknown error'}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
function ensureUv(installDir) {
|
|
41
60
|
const fromPath = which('uv');
|
|
42
61
|
if (fromPath) return fromPath;
|
|
43
62
|
const cachedUv = path.join(installDir, 'bin', 'uv');
|
|
44
63
|
if (existsSync(cachedUv)) return cachedUv;
|
|
45
64
|
mkdirSync(installDir, { recursive: true });
|
|
65
|
+
progress('Installing uv (Python toolchain) — first run only…');
|
|
46
66
|
const env = { ...process.env, UV_INSTALL_DIR: path.join(installDir, 'bin'), UV_NO_MODIFY_PATH: '1' };
|
|
47
67
|
const sh = spawnSync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | sh`], {
|
|
48
68
|
env,
|
|
@@ -50,7 +70,7 @@ function ensureUv(installDir) {
|
|
|
50
70
|
encoding: 'utf8',
|
|
51
71
|
});
|
|
52
72
|
if (sh.status !== 0) {
|
|
53
|
-
throw new Error(
|
|
73
|
+
throw new Error(describeStepFailure('uv install', sh, UV_TIMEOUT_MS));
|
|
54
74
|
}
|
|
55
75
|
if (!existsSync(cachedUv)) {
|
|
56
76
|
throw new Error(`uv install reported success but binary not found at ${cachedUv}`);
|
|
@@ -86,23 +106,27 @@ export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = fa
|
|
|
86
106
|
return { pythonBin: existingPython, venvDir, runtimeDir, fresh: false };
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
progress('Provisioning the docling document extractor (first run). This downloads a Python runtime and ML dependencies and can take several minutes; later runs are instant.');
|
|
89
110
|
const uv = ensureUv(runtimeDir);
|
|
90
111
|
|
|
112
|
+
progress('Creating Python 3.11 virtualenv…');
|
|
91
113
|
const venvResult = spawnSync(uv, ['venv', venvDir, '--python', '3.11'], {
|
|
92
114
|
timeout: VENV_TIMEOUT_MS,
|
|
93
115
|
encoding: 'utf8',
|
|
94
116
|
});
|
|
95
117
|
if (venvResult.status !== 0) {
|
|
96
|
-
throw new Error(
|
|
118
|
+
throw new Error(describeStepFailure('uv venv', venvResult, VENV_TIMEOUT_MS));
|
|
97
119
|
}
|
|
98
120
|
|
|
121
|
+
progress(`Installing docling ${DOCLING_PIN} and its dependencies — the slow step (large download)…`);
|
|
99
122
|
const installResult = spawnSync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], {
|
|
100
123
|
timeout: INSTALL_TIMEOUT_MS,
|
|
101
124
|
encoding: 'utf8',
|
|
102
125
|
});
|
|
103
126
|
if (installResult.status !== 0) {
|
|
104
|
-
throw new Error(
|
|
127
|
+
throw new Error(describeStepFailure('docling install', installResult, INSTALL_TIMEOUT_MS));
|
|
105
128
|
}
|
|
129
|
+
progress('docling extractor ready.');
|
|
106
130
|
|
|
107
131
|
const pythonBin = pythonBinFor(venvDir);
|
|
108
132
|
writeMarker(markerPath, {
|
package/lib/schema-infer.mjs
CHANGED
|
@@ -210,9 +210,23 @@ async function callModel(userContent, { traceId } = {}) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
// If only Anthropic key available, use configured fast tier directly.
|
|
213
|
+
// Construct ships with no implicit model defaults (ADR-0027,
|
|
214
|
+
// tests/model-router-no-defaults.test.mjs), so an unconfigured fast tier
|
|
215
|
+
// resolves to null. Surface a clear configuration error instead of letting
|
|
216
|
+
// null leak into the candidate loop where /^anthropic\//.test(null) throws.
|
|
217
|
+
|
|
213
218
|
if (candidates.length === 0 && anthropicKey) {
|
|
214
219
|
const envPath = getUserEnvPath();
|
|
215
|
-
|
|
220
|
+
const fast = readCurrentModels(envPath, {}).fast;
|
|
221
|
+
if (!fast) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"Schema inference requires a fast-tier model. " +
|
|
224
|
+
"Either set OPENROUTER_API_KEY (to use free model fallbacks) or " +
|
|
225
|
+
"configure a fast tier with 'construct models --apply' or " +
|
|
226
|
+
"'export CX_MODEL_FAST=<model-id>'."
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
candidates = [fast];
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
let lastError;
|
|
@@ -261,7 +275,7 @@ async function callModel(userContent, { traceId } = {}) {
|
|
|
261
275
|
headers: {
|
|
262
276
|
Authorization: `Bearer ${orKey}`,
|
|
263
277
|
'content-type': 'application/json',
|
|
264
|
-
'HTTP-Referer': 'https://github.com/construct',
|
|
278
|
+
'HTTP-Referer': 'https://github.com/geraldmaron/construct',
|
|
265
279
|
},
|
|
266
280
|
body: JSON.stringify({
|
|
267
281
|
model: openRouterModelId,
|
package/lib/server/csrf.mjs
CHANGED
|
@@ -66,6 +66,11 @@ export function ensureCsrfCookie(req, res) {
|
|
|
66
66
|
*/
|
|
67
67
|
export function verifyCsrf(req, { skip } = {}) {
|
|
68
68
|
if (SAFE_METHODS.has(req.method)) return true;
|
|
69
|
+
// A request authenticated by an Authorization header (bearer token) is not
|
|
70
|
+
// cookie-driven, so it cannot be forged cross-origin — CSRF defends ambient
|
|
71
|
+
// cookie auth, not header tokens. This is what lets programmatic API clients
|
|
72
|
+
// (editor adapters, CLI, CI) POST without the browser CSRF dance.
|
|
73
|
+
if (req.headers && req.headers.authorization) return true;
|
|
69
74
|
if (typeof skip === 'function' && skip(req.url || '')) return true;
|
|
70
75
|
const headerVal = req.headers[HEADER_NAME];
|
|
71
76
|
const cookieVal = parseCookies(req)[COOKIE_NAME];
|
|
@@ -78,13 +83,20 @@ export function verifyCsrf(req, { skip } = {}) {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
* Default skip predicate — webhooks
|
|
82
|
-
*
|
|
86
|
+
* Default skip predicate — webhooks, auth/login, and the orchestration API
|
|
87
|
+
* legitimately accept POSTs without the CSRF dance. CSRF defends a browser
|
|
88
|
+
* cookie session; the `/api/orchestration/*` endpoints are consumed only by
|
|
89
|
+
* programmatic clients (the `--remote` CLI, the MCP tool, editor adapters) that
|
|
90
|
+
* carry a bearer token (or nothing in no-token mode), never the dashboard
|
|
91
|
+
* cookie — so CSRF is the wrong control there, and the auth gate still protects
|
|
92
|
+
* them in token mode. Without this, a no-token daemon rejects every orchestration
|
|
93
|
+
* run with a 403.
|
|
83
94
|
*/
|
|
84
95
|
export function defaultSkip(url) {
|
|
85
96
|
if (!url) return false;
|
|
86
97
|
return (
|
|
87
98
|
url.startsWith('/api/webhooks/') ||
|
|
99
|
+
url.startsWith('/api/orchestration/') ||
|
|
88
100
|
url === '/api/auth/login' ||
|
|
89
101
|
url === '/api/slack/commands'
|
|
90
102
|
);
|
package/lib/server/index.mjs
CHANGED
|
@@ -32,6 +32,14 @@ import { onEmbedNotification } from '../embed/notifications.mjs';
|
|
|
32
32
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
33
33
|
const ROOT_DIR = join(__dirname, '..', '..');
|
|
34
34
|
const HOME = homedir();
|
|
35
|
+
|
|
36
|
+
// The daemon is a long-lived, detached service not bound to one project, so its
|
|
37
|
+
// orchestration runs persist under the user data root (HOME → ~/.cx/runtime/
|
|
38
|
+
// orchestration via the run store's relative path) rather than the install
|
|
39
|
+
// directory. A global install dir can be shared or read-only and would mix
|
|
40
|
+
// every project's runs together; HOME is always writable and survives reinstalls.
|
|
41
|
+
|
|
42
|
+
const ORCHESTRATION_RUN_ROOT = HOME;
|
|
35
43
|
const PORT = parseInt(process.env.PORT ?? '4242', 10);
|
|
36
44
|
|
|
37
45
|
// Default bind is loopback regardless of NODE_ENV. Exposing the dashboard to
|
|
@@ -464,6 +472,88 @@ async function handleApprovals(req, res) {
|
|
|
464
472
|
res.end('Method Not Allowed');
|
|
465
473
|
}
|
|
466
474
|
|
|
475
|
+
// Orchestration daemon surface: start a run (background execution), inspect runs,
|
|
476
|
+
// and stream a single run's lifecycle over SSE. Construct's runtime owns the
|
|
477
|
+
// orchestration; this lets any thin client (editor, TUI, CI) reach the outcome.
|
|
478
|
+
// Responses are wrapped in the versioned, secret-free embedded-contract envelope.
|
|
479
|
+
|
|
480
|
+
async function handleOrchestration(req, res) {
|
|
481
|
+
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
482
|
+
const { startRun, getRun, getRuns } = await import('../orchestration/runtime.mjs');
|
|
483
|
+
const { onRunEvent, requestCancel } = await import('../orchestration/events.mjs');
|
|
484
|
+
const { wrapResponse } = await import('../embedded-contract/envelope.mjs');
|
|
485
|
+
|
|
486
|
+
const send = (code, data) => {
|
|
487
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
488
|
+
res.end(JSON.stringify(wrapResponse({ data, surface: 'sdk' })));
|
|
489
|
+
};
|
|
490
|
+
const fail = (code, message) => {
|
|
491
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
492
|
+
res.end(JSON.stringify({ error: message }));
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
if (url.pathname === '/api/orchestration/runs' && req.method === 'POST') {
|
|
496
|
+
let body = '';
|
|
497
|
+
req.on('data', (c) => { body += c; });
|
|
498
|
+
req.on('end', async () => {
|
|
499
|
+
try {
|
|
500
|
+
const d = JSON.parse(body || '{}');
|
|
501
|
+
if (!d.request || typeof d.request !== 'string') throw new Error('Missing "request" string');
|
|
502
|
+
const run = await startRun(
|
|
503
|
+
{ request: d.request, workflowType: d.workflowType, requestedStrategy: d.requestedStrategy, host: d.host, hostModel: d.hostModel, hostProvider: d.hostProvider, fileCount: d.fileCount, moduleCount: d.moduleCount },
|
|
504
|
+
{ cwd: ORCHESTRATION_RUN_ROOT, workerBackend: d.workerBackend || null },
|
|
505
|
+
);
|
|
506
|
+
send(202, run);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
fail(400, err.message);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (url.pathname === '/api/orchestration/runs' && req.method === 'GET') {
|
|
515
|
+
const limit = Number(url.searchParams.get('limit') || 20);
|
|
516
|
+
send(200, { runs: await getRuns(ORCHESTRATION_RUN_ROOT, { limit }) });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const match = url.pathname.match(/^\/api\/orchestration\/runs\/([^/]+)(\/events|\/cancel)?$/);
|
|
521
|
+
if (match) {
|
|
522
|
+
const runId = decodeURIComponent(match[1]);
|
|
523
|
+
const sub = match[2];
|
|
524
|
+
|
|
525
|
+
if (!sub && req.method === 'GET') {
|
|
526
|
+
const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
|
|
527
|
+
if (!run) { fail(404, `Run not found: ${runId}`); return; }
|
|
528
|
+
send(200, run);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (sub === '/cancel' && req.method === 'POST') {
|
|
533
|
+
requestCancel(runId);
|
|
534
|
+
send(202, { runId, cancelRequested: true });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (sub === '/events' && req.method === 'GET') {
|
|
539
|
+
const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
|
|
540
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
541
|
+
res.write(': connected\n\n');
|
|
542
|
+
if (run) {
|
|
543
|
+
const snapshot = { type: 'snapshot', runId, status: run.status, chainOfThought: run.chainOfThought ?? null, tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })) };
|
|
544
|
+
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
545
|
+
}
|
|
546
|
+
const off = onRunEvent(runId, (event) => {
|
|
547
|
+
try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { off(); }
|
|
548
|
+
});
|
|
549
|
+
req.on('close', off);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
fail(404, 'Not found');
|
|
555
|
+
}
|
|
556
|
+
|
|
467
557
|
// ── Config handler ────────────────────────────────────────────────────────
|
|
468
558
|
|
|
469
559
|
function handleConfig(req, res) {
|
|
@@ -701,6 +791,11 @@ const server = createServer(async (req, res) => {
|
|
|
701
791
|
return;
|
|
702
792
|
}
|
|
703
793
|
|
|
794
|
+
if (url.pathname.startsWith('/api/orchestration/')) {
|
|
795
|
+
await handleOrchestration(req, res);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
704
799
|
if (url.pathname === '/api/registry' && req.method === 'GET') {
|
|
705
800
|
try {
|
|
706
801
|
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|