@geraldmaron/construct 1.0.20 → 1.0.23
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 -5
- package/bin/construct +194 -16
- 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 +44 -16
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +22 -0
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/document-ingest.mjs +13 -4
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +25 -7
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- 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/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- 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/ingest/strategy.mjs +35 -3
- 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/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/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +6 -2
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/runtime.mjs +188 -50
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +215 -0
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +80 -26
- 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 +171 -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/reconcile/postgres-namespace.mjs +102 -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 +39 -14
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +165 -141
- package/lib/storage/backend.mjs +14 -0
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/package.json +2 -2
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -4
- 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 +381 -95
- 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 +2 -11
- 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 +29 -29
- 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 +14 -14
- 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/lib/server/static/index.html +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/legacy-doctrine-strip.mjs — collapse a legacy un-fenced doctrine
|
|
3
|
+
* body in an existing AGENTS.md / CLAUDE.md down to the project header plus the
|
|
4
|
+
* marker blocks (ADR-0027 §2 backward-repair).
|
|
5
|
+
*
|
|
6
|
+
* An earlier `construct init` pre-wrote ~120 lines of generic Construct operating
|
|
7
|
+
* doctrine ABOVE the marker blocks, so a host repo carries Construct-the-tool
|
|
8
|
+
* content outside any fence — content `construct sync` cannot reconcile because
|
|
9
|
+
* the marker-only injector preserves everything outside its markers. This task
|
|
10
|
+
* removes the known doctrine sections while preserving: the project H1, any
|
|
11
|
+
* non-doctrine section the user added, and every marker block (BEADS, CONSTRUCT).
|
|
12
|
+
*
|
|
13
|
+
* Safety: `ask` — it edits a user-owned tracked file, so it never runs from the
|
|
14
|
+
* auto sync path and only applies on explicit `construct sync --reconcile=<id>`.
|
|
15
|
+
* detect() reads only; apply() is idempotent: a stripped file lacks the doctrine
|
|
16
|
+
* fingerprint, so a second detect() reports clean.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const AGENT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
23
|
+
const CONSTRUCT_MARKER = '<!-- BEGIN CONSTRUCT INTEGRATION';
|
|
24
|
+
const BEADS_MARKER = '<!-- BEGIN BEADS INTEGRATION';
|
|
25
|
+
|
|
26
|
+
// The H2 sections the retired buildAgentsGuide() authored. The five core
|
|
27
|
+
// headings form the fingerprint; the rest are stripped too when the head is
|
|
28
|
+
// confirmed as that doctrine.
|
|
29
|
+
|
|
30
|
+
const CORE_DOCTRINE_HEADINGS = [
|
|
31
|
+
'Operating hierarchy',
|
|
32
|
+
'Start-of-session rules',
|
|
33
|
+
'Maintenance rules',
|
|
34
|
+
'End-of-session rules',
|
|
35
|
+
'Verification rules',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const ALL_DOCTRINE_HEADINGS = new Set([
|
|
39
|
+
...CORE_DOCTRINE_HEADINGS,
|
|
40
|
+
'Parallel agent coordination',
|
|
41
|
+
'Documentation System',
|
|
42
|
+
'CI Enforcement',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function firstMarkerIndex(content) {
|
|
46
|
+
const idxs = [content.indexOf(BEADS_MARKER), content.indexOf(CONSTRUCT_MARKER)].filter((i) => i >= 0);
|
|
47
|
+
return idxs.length ? Math.min(...idxs) : -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitHeadTail(content) {
|
|
51
|
+
const idx = firstMarkerIndex(content);
|
|
52
|
+
if (idx < 0) return { head: content, tail: '' };
|
|
53
|
+
return { head: content.slice(0, idx), tail: content.slice(idx) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasDoctrine(head) {
|
|
57
|
+
return CORE_DOCTRINE_HEADINGS.every((h) => head.includes(`## ${h}`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deriveProjectName(head) {
|
|
61
|
+
const guide = head.match(/^#\s+(.+?)\s+Agent Guide\s*$/m);
|
|
62
|
+
if (guide) return guide[1].trim();
|
|
63
|
+
const h1 = head.match(/^#\s+(.+?)\s*$/m);
|
|
64
|
+
if (h1) return h1[1].trim();
|
|
65
|
+
return path.basename(process.cwd());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The rebuilt head is a plain project H1 followed by only the user-authored
|
|
69
|
+
// sections: any section whose heading is in the doctrine set is excluded, as are
|
|
70
|
+
// the generator comment and the "X Agent Guide" title.
|
|
71
|
+
|
|
72
|
+
function stripDoctrineFromHead(head, projectName) {
|
|
73
|
+
const withoutFrontmatter = head.replace(/^---\n[\s\S]*?\n---\s*\n/, '');
|
|
74
|
+
const withoutComment = withoutFrontmatter.replace(/^\s*<!--[\s\S]*?-->\s*/, '');
|
|
75
|
+
const afterH1 = withoutComment.replace(/^#\s+.+?\n+/, '');
|
|
76
|
+
const sections = afterH1.split(/\n(?=## )/).map((s) => s.trim()).filter(Boolean);
|
|
77
|
+
const kept = sections.filter((section) => {
|
|
78
|
+
const title = section.match(/^##\s+(.+?)\s*$/m)?.[1]?.trim();
|
|
79
|
+
return title ? !ALL_DOCTRINE_HEADINGS.has(title) : true;
|
|
80
|
+
});
|
|
81
|
+
const body = kept.length ? `${kept.join('\n\n')}\n\n` : '';
|
|
82
|
+
return `# ${projectName}\n\n${body}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function candidateFiles() {
|
|
86
|
+
const dir = process.cwd();
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const name of AGENT_FILES) {
|
|
89
|
+
const full = path.join(dir, name);
|
|
90
|
+
if (!fs.existsSync(full)) continue;
|
|
91
|
+
let content = '';
|
|
92
|
+
try {
|
|
93
|
+
content = fs.readFileSync(full, 'utf8');
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!content.includes(CONSTRUCT_MARKER)) continue;
|
|
98
|
+
const { head } = splitHeadTail(content);
|
|
99
|
+
if (!hasDoctrine(head)) continue;
|
|
100
|
+
out.push({ name, full, content });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function detect() {
|
|
106
|
+
const candidates = candidateFiles();
|
|
107
|
+
if (candidates.length === 0) {
|
|
108
|
+
return { needsRepair: false, summary: 'No agent file carries a legacy un-fenced doctrine body.' };
|
|
109
|
+
}
|
|
110
|
+
const names = candidates.map((c) => c.name);
|
|
111
|
+
return {
|
|
112
|
+
needsRepair: true,
|
|
113
|
+
summary: `Legacy un-fenced Construct doctrine in ${names.join(', ')}; collapse to the project header plus marker blocks.`,
|
|
114
|
+
details: { files: names },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function apply() {
|
|
119
|
+
const candidates = candidateFiles();
|
|
120
|
+
const stripped = [];
|
|
121
|
+
for (const c of candidates) {
|
|
122
|
+
const { head, tail } = splitHeadTail(c.content);
|
|
123
|
+
const projectName = deriveProjectName(head);
|
|
124
|
+
const next = stripDoctrineFromHead(head, projectName) + tail;
|
|
125
|
+
if (next === c.content) continue;
|
|
126
|
+
fs.writeFileSync(c.full, next, 'utf8');
|
|
127
|
+
stripped.push(c.name);
|
|
128
|
+
}
|
|
129
|
+
if (stripped.length === 0) return { summary: 'No legacy doctrine to strip.' };
|
|
130
|
+
return { summary: `Stripped legacy doctrine from ${stripped.join(', ')}; user content and marker blocks preserved.` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
id: 'legacy-doctrine-strip',
|
|
135
|
+
description: 'Collapse a legacy un-fenced Construct doctrine body in AGENTS.md / CLAUDE.md to the project header plus marker blocks.',
|
|
136
|
+
safety: 'ask',
|
|
137
|
+
detect,
|
|
138
|
+
apply,
|
|
139
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/legacy-guide-decommit.mjs — relocate a legacy root-level
|
|
3
|
+
* construct_guide.md into the ignored .cx/ tree (ADR-0027 §1 backward-repair).
|
|
4
|
+
*
|
|
5
|
+
* An earlier `construct init` copied the "Welcome to Construct" orientation guide
|
|
6
|
+
* to the host repo root, where it reads as project content and is git-eligible.
|
|
7
|
+
* The canonical home is `.cx/construct_guide.md` (ignored). This task ensures the
|
|
8
|
+
* content survives under .cx/ and removes the root copy; when the root copy was
|
|
9
|
+
* committed, the summary names the `git rm --cached` follow-up (the reconcile
|
|
10
|
+
* framework runs no git on the host).
|
|
11
|
+
*
|
|
12
|
+
* Safety: `ask` — editing the working tree of a possibly-committed file requires
|
|
13
|
+
* explicit consent, so the auto sync path skips this task; only
|
|
14
|
+
* `construct sync --reconcile=<id>` applies it. detect() reads only; apply() is
|
|
15
|
+
* idempotent: a moved guide leaves no root-level file for the next detect().
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const ROOT_NAME = 'construct_guide.md';
|
|
22
|
+
|
|
23
|
+
function paths() {
|
|
24
|
+
const dir = process.cwd();
|
|
25
|
+
return {
|
|
26
|
+
root: path.join(dir, ROOT_NAME),
|
|
27
|
+
dest: path.join(dir, '.cx', ROOT_NAME),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function detect() {
|
|
32
|
+
const { root } = paths();
|
|
33
|
+
if (!fs.existsSync(root)) {
|
|
34
|
+
return { needsRepair: false, summary: 'No root-level construct_guide.md.' };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
needsRepair: true,
|
|
38
|
+
summary: 'Root-level construct_guide.md presents Construct tooling as project content; relocate to .cx/ (ignored).',
|
|
39
|
+
details: { path: ROOT_NAME },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function apply() {
|
|
44
|
+
const { root, dest } = paths();
|
|
45
|
+
if (!fs.existsSync(root)) return { summary: 'Nothing to relocate.' };
|
|
46
|
+
|
|
47
|
+
// Preserve content under the ignored canonical home, then drop the root copy.
|
|
48
|
+
// An existing .cx/ guide is the canonical one and is left intact.
|
|
49
|
+
|
|
50
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
51
|
+
if (!fs.existsSync(dest)) {
|
|
52
|
+
fs.copyFileSync(root, dest);
|
|
53
|
+
}
|
|
54
|
+
fs.rmSync(root);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
summary: `Relocated ${ROOT_NAME} to .cx/${ROOT_NAME} (ignored). If it was committed, run: git rm --cached ${ROOT_NAME}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default {
|
|
62
|
+
id: 'legacy-guide-decommit',
|
|
63
|
+
description: 'Relocate a legacy root-level construct_guide.md into the ignored .cx/ tree.',
|
|
64
|
+
safety: 'ask',
|
|
65
|
+
detect,
|
|
66
|
+
apply,
|
|
67
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
};
|