@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,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/adapter-prune.mjs — report and remove adapter directories for
|
|
3
|
+
* hosts not installed on this machine (ADR-0027 §1).
|
|
4
|
+
*
|
|
5
|
+
* `construct sync` writes adapter sets for the hosts it detects. A project
|
|
6
|
+
* synced where more hosts were installed, then opened on a leaner machine,
|
|
7
|
+
* carries adapter dirs for hosts absent from the current machine. Each adapter
|
|
8
|
+
* dir is sync-regenerated machine-specific state, so pruning the dirs for
|
|
9
|
+
* undetected hosts is safe — a later sync recreates whatever the current
|
|
10
|
+
* machine actually has.
|
|
11
|
+
*
|
|
12
|
+
* `.claude` stays untouched: it is the baseline set that VS Code reads
|
|
13
|
+
* natively, so its presence carries no implication about Claude Code. `.github`
|
|
14
|
+
* (Copilot prompts / instructions) is out of scope entirely.
|
|
15
|
+
*
|
|
16
|
+
* Safety: `ask`. Deleting generated directories runs only on explicit consent
|
|
17
|
+
* (`construct sync --reconcile=adapter-prune`), and the auto runner skips ask
|
|
18
|
+
* tasks. detect() reads only; apply() is idempotent because a removed dir drops
|
|
19
|
+
* out of the present set.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { ADAPTER_DIRS } from '../host-disposition.mjs';
|
|
26
|
+
import { detectHostCapabilities } from '../host-capabilities.mjs';
|
|
27
|
+
|
|
28
|
+
const HOST_TO_DIR = {
|
|
29
|
+
'Claude Code': '.claude',
|
|
30
|
+
OpenCode: '.opencode',
|
|
31
|
+
Codex: '.codex',
|
|
32
|
+
'VS Code': '.vscode',
|
|
33
|
+
Cursor: '.cursor',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const PROTECTED_DIRS = new Set(['.claude']);
|
|
37
|
+
|
|
38
|
+
function isConstructProject(dir) {
|
|
39
|
+
return fs.existsSync(path.join(dir, '.cx')) || fs.existsSync(path.join(dir, '.construct'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function detectedDirSet() {
|
|
43
|
+
const dirs = new Set();
|
|
44
|
+
for (const host of detectHostCapabilities()) {
|
|
45
|
+
if (host.availability !== 'installed') continue;
|
|
46
|
+
const dir = HOST_TO_DIR[host.host];
|
|
47
|
+
if (dir) dirs.add(dir);
|
|
48
|
+
}
|
|
49
|
+
return dirs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A candidate is a present adapter dir whose host is not installed, excluding
|
|
53
|
+
// the protected baseline `.claude` that VS Code consumes natively.
|
|
54
|
+
|
|
55
|
+
function prunableDirs(projectDir) {
|
|
56
|
+
const detected = detectedDirSet();
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const dir of ADAPTER_DIRS) {
|
|
59
|
+
if (PROTECTED_DIRS.has(dir)) continue;
|
|
60
|
+
if (detected.has(dir)) continue;
|
|
61
|
+
if (fs.existsSync(path.join(projectDir, dir))) out.push(dir);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function detect() {
|
|
67
|
+
const dir = process.cwd();
|
|
68
|
+
if (!isConstructProject(dir)) {
|
|
69
|
+
return { needsRepair: false, summary: 'Not a Construct project directory.' };
|
|
70
|
+
}
|
|
71
|
+
const prunable = prunableDirs(dir);
|
|
72
|
+
if (prunable.length === 0) {
|
|
73
|
+
return { needsRepair: false, summary: 'No adapter directories for uninstalled hosts.' };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
needsRepair: true,
|
|
77
|
+
summary: `${prunable.length} adapter director${prunable.length === 1 ? 'y' : 'ies'} for uninstalled hosts: ${prunable.join(', ')}.`,
|
|
78
|
+
details: { prunable },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function apply() {
|
|
83
|
+
const dir = process.cwd();
|
|
84
|
+
const prunable = prunableDirs(dir);
|
|
85
|
+
const removed = [];
|
|
86
|
+
for (const adapter of prunable) {
|
|
87
|
+
const full = path.join(dir, adapter);
|
|
88
|
+
try {
|
|
89
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
90
|
+
removed.push(adapter);
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (removed.length === 0) return { summary: 'Nothing to prune.' };
|
|
96
|
+
return { summary: `Removed ${removed.length} adapter director${removed.length === 1 ? 'y' : 'ies'} for uninstalled hosts: ${removed.join(', ')}.` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default {
|
|
100
|
+
id: 'adapter-prune',
|
|
101
|
+
description: 'Remove adapter directories for hosts not installed on this machine (never .claude or .github).',
|
|
102
|
+
safety: 'ask',
|
|
103
|
+
detect,
|
|
104
|
+
apply,
|
|
105
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/agent-instructions-rewrap.mjs — keep the CONSTRUCT INTEGRATION
|
|
3
|
+
* block current inside existing AGENTS.md / CLAUDE.md files (ADR-0027 §2).
|
|
4
|
+
*
|
|
5
|
+
* The integration block is versioned and hash-stamped. A changed managed body
|
|
6
|
+
* or version leaves files written by an earlier init carrying a stale block.
|
|
7
|
+
* Re-injection rewrites the current block into files that already contain one,
|
|
8
|
+
* replacing only the marker region and preserving every byte outside it.
|
|
9
|
+
*
|
|
10
|
+
* Scope is deliberately narrow: a file is touched only when it EXISTS in cwd
|
|
11
|
+
* and already carries a CONSTRUCT INTEGRATION marker. Files without a block are
|
|
12
|
+
* left for init to create — backward-repair never authors a block into a file
|
|
13
|
+
* the user never opted into. Safety: `auto`. detect() reads only; apply() is
|
|
14
|
+
* idempotent because injectIntoAgentFile returns `unchanged` once the stamp
|
|
15
|
+
* matches.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
injectIntoAgentFile,
|
|
23
|
+
injectConstructBlock,
|
|
24
|
+
buildConstructIntegrationBody,
|
|
25
|
+
CONSTRUCT_INTEGRATION_VERSION,
|
|
26
|
+
} from '../agent-instructions/inject.mjs';
|
|
27
|
+
|
|
28
|
+
const AGENT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
29
|
+
const BEGIN_MARKER = '<!-- BEGIN CONSTRUCT INTEGRATION';
|
|
30
|
+
const BEADS_BLOCK_RE = /<!-- BEGIN BEADS INTEGRATION/;
|
|
31
|
+
|
|
32
|
+
function hasIntegrationBlock(content) {
|
|
33
|
+
return content.includes(BEGIN_MARKER);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Re-injection that would change the file maps to a stale block. The body
|
|
37
|
+
// dedups against a sibling Beads block, so the comparison rebuilds the body
|
|
38
|
+
// the same way injectIntoAgentFile does before hashing.
|
|
39
|
+
|
|
40
|
+
function wouldChange(content) {
|
|
41
|
+
const body = buildConstructIntegrationBody({ hasBeadsBlock: BEADS_BLOCK_RE.test(content) });
|
|
42
|
+
const { action } = injectConstructBlock(content, body, CONSTRUCT_INTEGRATION_VERSION);
|
|
43
|
+
return action !== 'unchanged';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function candidateFiles() {
|
|
47
|
+
const dir = process.cwd();
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const name of AGENT_FILES) {
|
|
50
|
+
const full = path.join(dir, name);
|
|
51
|
+
if (!fs.existsSync(full)) continue;
|
|
52
|
+
let content = '';
|
|
53
|
+
try {
|
|
54
|
+
content = fs.readFileSync(full, 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!hasIntegrationBlock(content)) continue;
|
|
59
|
+
out.push({ name, full, content });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function detect() {
|
|
65
|
+
const candidates = candidateFiles();
|
|
66
|
+
if (candidates.length === 0) {
|
|
67
|
+
return { needsRepair: false, summary: 'No agent file carries a CONSTRUCT INTEGRATION block.' };
|
|
68
|
+
}
|
|
69
|
+
const stale = candidates.filter((c) => wouldChange(c.content)).map((c) => c.name);
|
|
70
|
+
if (stale.length === 0) {
|
|
71
|
+
return { needsRepair: false, summary: 'CONSTRUCT INTEGRATION blocks are current.' };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
needsRepair: true,
|
|
75
|
+
summary: `Stale CONSTRUCT INTEGRATION block in ${stale.join(', ')}.`,
|
|
76
|
+
details: { stale },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function apply() {
|
|
81
|
+
const candidates = candidateFiles();
|
|
82
|
+
const rewrapped = [];
|
|
83
|
+
for (const c of candidates) {
|
|
84
|
+
if (!wouldChange(c.content)) continue;
|
|
85
|
+
const result = injectIntoAgentFile(c.full, { version: CONSTRUCT_INTEGRATION_VERSION });
|
|
86
|
+
if (result.changed) rewrapped.push(c.name);
|
|
87
|
+
}
|
|
88
|
+
if (rewrapped.length === 0) return { summary: 'Already current.' };
|
|
89
|
+
return { summary: `Rewrapped CONSTRUCT INTEGRATION block in ${rewrapped.join(', ')}.` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
id: 'agent-instructions-rewrap',
|
|
94
|
+
description: 'Refresh stale CONSTRUCT INTEGRATION blocks in existing AGENTS.md / CLAUDE.md files.',
|
|
95
|
+
safety: 'auto',
|
|
96
|
+
detect,
|
|
97
|
+
apply,
|
|
98
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/gitignore-coverage.mjs — repair a project `.gitignore` that
|
|
3
|
+
* predates the full Construct ignore set (ADR-0027 §1).
|
|
4
|
+
*
|
|
5
|
+
* The init writer seeds every machine-specific, sync-regenerated artifact into
|
|
6
|
+
* `.gitignore` so adapter dirs, the launcher, and runtime state never enter
|
|
7
|
+
* source control. A project initialized before a pattern was added carries a
|
|
8
|
+
* partial set; this task appends the missing patterns under the same comment
|
|
9
|
+
* header the init writer uses, leaving all user-authored lines untouched.
|
|
10
|
+
*
|
|
11
|
+
* Scope: a Construct project directory (cwd has `.cx/` or `.construct/`) that
|
|
12
|
+
* already has a `.gitignore`. Absent either, there is nothing to repair — init
|
|
13
|
+
* owns creation. Safety: `auto`. detect() reads only; apply() appends and is
|
|
14
|
+
* idempotent because missingIgnorePatterns() drains to empty once the patterns
|
|
15
|
+
* are present.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { missingIgnorePatterns, isConstructPackageRepo } from '../host-disposition.mjs';
|
|
22
|
+
|
|
23
|
+
const HEADER = '# Construct — generated adapters, launcher, and runtime state.';
|
|
24
|
+
const SUBHEADER = '# Machine-specific, recreated by `construct sync`; never source (ADR-0027).';
|
|
25
|
+
|
|
26
|
+
function isConstructProject(dir) {
|
|
27
|
+
return fs.existsSync(path.join(dir, '.cx')) || fs.existsSync(path.join(dir, '.construct'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function gitignorePath(dir) {
|
|
31
|
+
return path.join(dir, '.gitignore');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function detect() {
|
|
35
|
+
const dir = process.cwd();
|
|
36
|
+
if (!isConstructProject(dir)) {
|
|
37
|
+
return { needsRepair: false, summary: 'Not a Construct project directory.' };
|
|
38
|
+
}
|
|
39
|
+
if (isConstructPackageRepo(dir)) {
|
|
40
|
+
return { needsRepair: false, summary: 'Construct package repo — .construct/ holds shipped templates, not a staged launcher.' };
|
|
41
|
+
}
|
|
42
|
+
const giPath = gitignorePath(dir);
|
|
43
|
+
if (!fs.existsSync(giPath)) {
|
|
44
|
+
return { needsRepair: false, summary: 'No .gitignore present (init owns creation).' };
|
|
45
|
+
}
|
|
46
|
+
let content = '';
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(giPath, 'utf8');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { needsRepair: false, summary: `Could not read .gitignore: ${err.message}` };
|
|
51
|
+
}
|
|
52
|
+
const missing = missingIgnorePatterns(content);
|
|
53
|
+
if (missing.length === 0) {
|
|
54
|
+
return { needsRepair: false, summary: '.gitignore covers the full Construct ignore set.' };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
needsRepair: true,
|
|
58
|
+
summary: `${missing.length} Construct ignore pattern${missing.length === 1 ? '' : 's'} missing from .gitignore.`,
|
|
59
|
+
details: { missing },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function apply() {
|
|
64
|
+
const dir = process.cwd();
|
|
65
|
+
const giPath = gitignorePath(dir);
|
|
66
|
+
if (!fs.existsSync(giPath)) return { summary: 'No .gitignore to repair.' };
|
|
67
|
+
const existing = fs.readFileSync(giPath, 'utf8');
|
|
68
|
+
const missing = missingIgnorePatterns(existing);
|
|
69
|
+
if (missing.length === 0) return { summary: 'Already covered.' };
|
|
70
|
+
|
|
71
|
+
// Mirror the init writer's separator and header so forward-fix and
|
|
72
|
+
// backward-repair produce byte-identical blocks.
|
|
73
|
+
|
|
74
|
+
const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
|
|
75
|
+
const block = `${prefix}\n${HEADER}\n${SUBHEADER}\n${missing.join('\n')}\n`;
|
|
76
|
+
fs.writeFileSync(giPath, existing + block, 'utf8');
|
|
77
|
+
return {
|
|
78
|
+
summary: `Appended ${missing.length} Construct ignore pattern${missing.length === 1 ? '' : 's'} to .gitignore.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default {
|
|
83
|
+
id: 'gitignore-coverage',
|
|
84
|
+
description: 'Append missing Construct ignore patterns to a project .gitignore that predates the full set.',
|
|
85
|
+
safety: 'auto',
|
|
86
|
+
detect,
|
|
87
|
+
apply,
|
|
88
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reconcile/index.mjs — registry of state-reconciliation tasks that
|
|
3
|
+
* bring on-disk state in line with the expected disposition contract
|
|
4
|
+
* (ADR-0027). Separate from lib/migrations/ (artifact-JSON schema
|
|
5
|
+
* versioning) and lib/storage/migrations.mjs (Postgres schema).
|
|
6
|
+
*
|
|
7
|
+
* Each task declares: id (kebab), description (one line), safety
|
|
8
|
+
* (`auto` = run silently from `construct sync` when detect() reports
|
|
9
|
+
* needsRepair; `ask` = surfaced by `construct doctor` and runs only on
|
|
10
|
+
* explicit `construct sync --reconcile=<id>`), detect() (async, cheap,
|
|
11
|
+
* side-effect-free, returns { needsRepair, summary, ... }), and apply()
|
|
12
|
+
* (async, idempotent, returns { summary }).
|
|
13
|
+
*
|
|
14
|
+
* Two invariants every task must hold:
|
|
15
|
+
* 1. Never touch a user-authored file without consent — `safety: 'ask'`
|
|
16
|
+
* gates anything that could remove user edits, and detect() must
|
|
17
|
+
* exclude paths whose mtime suggests user interaction.
|
|
18
|
+
* 2. Calling detect() after apply() must return needsRepair: false on
|
|
19
|
+
* the same input. A task that would loop is a detection bug — the
|
|
20
|
+
* stamp file is a hint and a record, not a correctness guard.
|
|
21
|
+
*
|
|
22
|
+
* Stamps land at ~/.construct/reconcile.json so the dashboard and doctor
|
|
23
|
+
* can show what's been applied; loss of the file does not break apply()
|
|
24
|
+
* (apply() stays idempotent on its own).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { constructDir } from '../paths.mjs';
|
|
31
|
+
import legacySkillsCleanup from './legacy-skills-cleanup.mjs';
|
|
32
|
+
import gitignoreCoverage from './gitignore-coverage.mjs';
|
|
33
|
+
import agentInstructionsRewrap from './agent-instructions-rewrap.mjs';
|
|
34
|
+
import legacyDoctrineStrip from './legacy-doctrine-strip.mjs';
|
|
35
|
+
import legacyGuideDecommit from './legacy-guide-decommit.mjs';
|
|
36
|
+
import mcpEntryReconcile from './mcp-entry-reconcile.mjs';
|
|
37
|
+
import adapterPrune from './adapter-prune.mjs';
|
|
38
|
+
|
|
39
|
+
export const RECONCILIATIONS = [
|
|
40
|
+
legacySkillsCleanup,
|
|
41
|
+
gitignoreCoverage,
|
|
42
|
+
agentInstructionsRewrap,
|
|
43
|
+
legacyDoctrineStrip,
|
|
44
|
+
legacyGuideDecommit,
|
|
45
|
+
mcpEntryReconcile,
|
|
46
|
+
adapterPrune,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const STAMP_FILE = 'reconcile.json';
|
|
50
|
+
|
|
51
|
+
function stampPath() {
|
|
52
|
+
return path.join(constructDir(), STAMP_FILE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStamps() {
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(stampPath(), 'utf8');
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
return parsed && typeof parsed === 'object' && parsed.applied
|
|
60
|
+
? parsed
|
|
61
|
+
: { applied: {} };
|
|
62
|
+
} catch {
|
|
63
|
+
return { applied: {} };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeStamp(id, summary) {
|
|
68
|
+
const stamps = readStamps();
|
|
69
|
+
stamps.applied[id] = { at: new Date().toISOString(), summary };
|
|
70
|
+
try {
|
|
71
|
+
fs.mkdirSync(path.dirname(stampPath()), { recursive: true });
|
|
72
|
+
fs.writeFileSync(stampPath(), JSON.stringify(stamps, null, 2));
|
|
73
|
+
} catch {
|
|
74
|
+
// Stamp failure is non-fatal — apply() must be idempotent so a missing
|
|
75
|
+
// stamp just means the next detect() catches the same state again.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function safeDetect(task) {
|
|
80
|
+
try {
|
|
81
|
+
const result = await task.detect();
|
|
82
|
+
return result && typeof result === 'object'
|
|
83
|
+
? { needsRepair: !!result.needsRepair, summary: result.summary || null, details: result.details || null }
|
|
84
|
+
: { needsRepair: false, summary: null, details: null };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { needsRepair: false, summary: null, details: null, error: err.message };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function listReconciliations() {
|
|
91
|
+
const stamps = readStamps();
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const task of RECONCILIATIONS) {
|
|
94
|
+
const detected = await safeDetect(task);
|
|
95
|
+
out.push({
|
|
96
|
+
id: task.id,
|
|
97
|
+
description: task.description,
|
|
98
|
+
safety: task.safety,
|
|
99
|
+
needsRepair: detected.needsRepair,
|
|
100
|
+
summary: detected.summary,
|
|
101
|
+
details: detected.details,
|
|
102
|
+
error: detected.error || null,
|
|
103
|
+
previouslyApplied: !!stamps.applied[task.id],
|
|
104
|
+
appliedAt: stamps.applied[task.id]?.at || null,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function runAutoReconciliations({ dryRun = false, verbose = false, logger = console } = {}) {
|
|
111
|
+
const applied = [];
|
|
112
|
+
const skipped = [];
|
|
113
|
+
for (const task of RECONCILIATIONS) {
|
|
114
|
+
if (task.safety !== 'auto') {
|
|
115
|
+
skipped.push({ id: task.id, reason: 'safety:ask' });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const detected = await safeDetect(task);
|
|
119
|
+
if (detected.error) {
|
|
120
|
+
skipped.push({ id: task.id, reason: `detect-error:${detected.error}` });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!detected.needsRepair) {
|
|
124
|
+
skipped.push({ id: task.id, reason: 'no-op' });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (dryRun) {
|
|
128
|
+
applied.push({ id: task.id, summary: detected.summary, dryRun: true });
|
|
129
|
+
if (verbose && logger?.log) logger.log(`[reconcile:${task.id}] would apply: ${detected.summary}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const result = await task.apply();
|
|
134
|
+
const summary = result?.summary || detected.summary || '';
|
|
135
|
+
writeStamp(task.id, summary);
|
|
136
|
+
applied.push({ id: task.id, summary });
|
|
137
|
+
if (verbose && logger?.log) logger.log(`[reconcile:${task.id}] ${summary}`);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
skipped.push({ id: task.id, reason: `apply-error:${err.message}` });
|
|
140
|
+
if (verbose && logger?.error) logger.error(`[reconcile:${task.id}] failed: ${err.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { applied, skipped };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function runReconciliation(id, { dryRun = false } = {}) {
|
|
147
|
+
const task = RECONCILIATIONS.find((x) => x.id === id);
|
|
148
|
+
if (!task) {
|
|
149
|
+
return { ok: false, reason: `Unknown reconciliation: ${id}`, available: RECONCILIATIONS.map((x) => x.id) };
|
|
150
|
+
}
|
|
151
|
+
const detected = await safeDetect(task);
|
|
152
|
+
if (detected.error) return { ok: false, reason: `detect-error:${detected.error}` };
|
|
153
|
+
if (!detected.needsRepair) {
|
|
154
|
+
return { ok: true, ran: false, reason: 'no-op', summary: detected.summary || 'Nothing to reconcile.' };
|
|
155
|
+
}
|
|
156
|
+
if (dryRun) return { ok: true, ran: false, dryRun: true, summary: detected.summary };
|
|
157
|
+
try {
|
|
158
|
+
const result = await task.apply();
|
|
159
|
+
const summary = result?.summary || detected.summary || '';
|
|
160
|
+
writeStamp(id, summary);
|
|
161
|
+
return { ok: true, ran: true, summary };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return { ok: false, ran: false, reason: `apply-error:${err.message}` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function reconcileStampPath() {
|
|
168
|
+
return stampPath();
|
|
169
|
+
}
|
|
@@ -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
|
+
};
|