@geraldmaron/construct 1.0.21 → 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.
Files changed (117) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +191 -13
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +25 -7
  17. package/lib/features.mjs +11 -11
  18. package/lib/git-hooks-path.mjs +61 -0
  19. package/lib/home-namespace.mjs +60 -0
  20. package/lib/hooks/ci-status-check.mjs +62 -40
  21. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  22. package/lib/hooks/pre-push-gate.mjs +15 -6
  23. package/lib/hooks/stop-notify.mjs +32 -17
  24. package/lib/hooks/stop-typecheck.mjs +7 -2
  25. package/lib/host-capabilities.mjs +24 -8
  26. package/lib/host-disposition.mjs +76 -0
  27. package/lib/ingest/provider-extract.mjs +1 -1
  28. package/lib/init-docs.mjs +1 -1
  29. package/lib/init-unified.mjs +320 -219
  30. package/lib/init-update.mjs +4 -84
  31. package/lib/init.mjs +9 -25
  32. package/lib/install/stage-project.mjs +8 -2
  33. package/lib/intent-classifier.mjs +1 -1
  34. package/lib/knowledge/search.mjs +52 -3
  35. package/lib/mcp/server.mjs +57 -14
  36. package/lib/mcp/tools/memory.mjs +2 -2
  37. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  38. package/lib/model-registry.mjs +40 -33
  39. package/lib/observation-store.mjs +6 -2
  40. package/lib/opencode-config.mjs +1 -1
  41. package/lib/orchestration/events.mjs +66 -0
  42. package/lib/orchestration/runtime.mjs +70 -9
  43. package/lib/orchestration/worker.mjs +69 -21
  44. package/lib/orchestration-policy.mjs +27 -3
  45. package/lib/parity.mjs +46 -24
  46. package/lib/policy/unified-gates.mjs +96 -0
  47. package/lib/project-init-shared.mjs +0 -173
  48. package/lib/reconcile/adapter-prune.mjs +105 -0
  49. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  50. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  51. package/lib/reconcile/index.mjs +171 -0
  52. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  53. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  54. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  55. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  56. package/lib/reconcile/postgres-namespace.mjs +102 -0
  57. package/lib/runtime/uv-bootstrap.mjs +27 -3
  58. package/lib/schema-infer.mjs +16 -2
  59. package/lib/server/csrf.mjs +14 -2
  60. package/lib/server/index.mjs +95 -0
  61. package/lib/service-manager.mjs +39 -14
  62. package/lib/setup-prompts.mjs +2 -1
  63. package/lib/setup.mjs +165 -141
  64. package/lib/storage/backend.mjs +14 -0
  65. package/lib/storage/unified-storage.mjs +550 -0
  66. package/lib/template-registry.mjs +73 -0
  67. package/lib/term-format.mjs +75 -0
  68. package/lib/uninstall/uninstall.mjs +180 -7
  69. package/package.json +2 -2
  70. package/personas/construct.md +7 -8
  71. package/platforms/claude/settings.template.json +30 -4
  72. package/platforms/opencode/config.template.json +2 -2
  73. package/rules/common/neurodivergent-output.md +66 -0
  74. package/rules/common/tool-invisibility.md +37 -0
  75. package/scripts/sync-specialists.mjs +381 -95
  76. package/skills/operating/orchestration-reference.md +2 -16
  77. package/specialists/policy-inventory.json +14 -0
  78. package/specialists/prompts/cx-accessibility.md +2 -6
  79. package/specialists/prompts/cx-ai-engineer.md +0 -4
  80. package/specialists/prompts/cx-architect.md +3 -5
  81. package/specialists/prompts/cx-business-strategist.md +0 -5
  82. package/specialists/prompts/cx-data-analyst.md +0 -4
  83. package/specialists/prompts/cx-data-engineer.md +0 -4
  84. package/specialists/prompts/cx-debugger.md +2 -6
  85. package/specialists/prompts/cx-designer.md +0 -8
  86. package/specialists/prompts/cx-devil-advocate.md +2 -2
  87. package/specialists/prompts/cx-docs-keeper.md +0 -13
  88. package/specialists/prompts/cx-engineer.md +0 -13
  89. package/specialists/prompts/cx-evaluator.md +2 -2
  90. package/specialists/prompts/cx-explorer.md +4 -5
  91. package/specialists/prompts/cx-legal-compliance.md +4 -5
  92. package/specialists/prompts/cx-operations.md +0 -5
  93. package/specialists/prompts/cx-orchestrator.md +0 -4
  94. package/specialists/prompts/cx-platform-engineer.md +0 -8
  95. package/specialists/prompts/cx-product-manager.md +0 -8
  96. package/specialists/prompts/cx-qa.md +2 -11
  97. package/specialists/prompts/cx-rd-lead.md +0 -5
  98. package/specialists/prompts/cx-release-manager.md +0 -8
  99. package/specialists/prompts/cx-researcher.md +5 -29
  100. package/specialists/prompts/cx-reviewer.md +2 -6
  101. package/specialists/prompts/cx-security.md +2 -11
  102. package/specialists/prompts/cx-sre.md +0 -15
  103. package/specialists/prompts/cx-test-automation.md +0 -4
  104. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  105. package/specialists/prompts/cx-ux-researcher.md +0 -4
  106. package/specialists/registry.json +29 -29
  107. package/templates/distribution/run.mjs +36 -7
  108. package/templates/docs/accessibility-audit.md +56 -0
  109. package/templates/docs/architecture-review.md +59 -0
  110. package/templates/docs/code-review-report.md +46 -0
  111. package/templates/docs/construct_guide.md +14 -14
  112. package/templates/docs/debug-investigation.md +53 -0
  113. package/templates/docs/qa-report.md +48 -0
  114. package/templates/docs/security-audit-report.md +48 -0
  115. package/templates/docs/task-packet.md +49 -0
  116. package/templates/docs/verdict.md +40 -0
  117. package/lib/server/static/index.html +0 -1
@@ -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,171 @@
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
+ import postgresNamespace from './postgres-namespace.mjs';
39
+
40
+ export const RECONCILIATIONS = [
41
+ legacySkillsCleanup,
42
+ gitignoreCoverage,
43
+ agentInstructionsRewrap,
44
+ legacyDoctrineStrip,
45
+ legacyGuideDecommit,
46
+ mcpEntryReconcile,
47
+ adapterPrune,
48
+ postgresNamespace,
49
+ ];
50
+
51
+ const STAMP_FILE = 'reconcile.json';
52
+
53
+ function stampPath() {
54
+ return path.join(constructDir(), STAMP_FILE);
55
+ }
56
+
57
+ function readStamps() {
58
+ try {
59
+ const raw = fs.readFileSync(stampPath(), 'utf8');
60
+ const parsed = JSON.parse(raw);
61
+ return parsed && typeof parsed === 'object' && parsed.applied
62
+ ? parsed
63
+ : { applied: {} };
64
+ } catch {
65
+ return { applied: {} };
66
+ }
67
+ }
68
+
69
+ function writeStamp(id, summary) {
70
+ const stamps = readStamps();
71
+ stamps.applied[id] = { at: new Date().toISOString(), summary };
72
+ try {
73
+ fs.mkdirSync(path.dirname(stampPath()), { recursive: true });
74
+ fs.writeFileSync(stampPath(), JSON.stringify(stamps, null, 2));
75
+ } catch {
76
+ // Stamp failure is non-fatal — apply() must be idempotent so a missing
77
+ // stamp just means the next detect() catches the same state again.
78
+ }
79
+ }
80
+
81
+ async function safeDetect(task) {
82
+ try {
83
+ const result = await task.detect();
84
+ return result && typeof result === 'object'
85
+ ? { needsRepair: !!result.needsRepair, summary: result.summary || null, details: result.details || null }
86
+ : { needsRepair: false, summary: null, details: null };
87
+ } catch (err) {
88
+ return { needsRepair: false, summary: null, details: null, error: err.message };
89
+ }
90
+ }
91
+
92
+ export async function listReconciliations() {
93
+ const stamps = readStamps();
94
+ const out = [];
95
+ for (const task of RECONCILIATIONS) {
96
+ const detected = await safeDetect(task);
97
+ out.push({
98
+ id: task.id,
99
+ description: task.description,
100
+ safety: task.safety,
101
+ needsRepair: detected.needsRepair,
102
+ summary: detected.summary,
103
+ details: detected.details,
104
+ error: detected.error || null,
105
+ previouslyApplied: !!stamps.applied[task.id],
106
+ appliedAt: stamps.applied[task.id]?.at || null,
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export async function runAutoReconciliations({ dryRun = false, verbose = false, logger = console } = {}) {
113
+ const applied = [];
114
+ const skipped = [];
115
+ for (const task of RECONCILIATIONS) {
116
+ if (task.safety !== 'auto') {
117
+ skipped.push({ id: task.id, reason: 'safety:ask' });
118
+ continue;
119
+ }
120
+ const detected = await safeDetect(task);
121
+ if (detected.error) {
122
+ skipped.push({ id: task.id, reason: `detect-error:${detected.error}` });
123
+ continue;
124
+ }
125
+ if (!detected.needsRepair) {
126
+ skipped.push({ id: task.id, reason: 'no-op' });
127
+ continue;
128
+ }
129
+ if (dryRun) {
130
+ applied.push({ id: task.id, summary: detected.summary, dryRun: true });
131
+ if (verbose && logger?.log) logger.log(`[reconcile:${task.id}] would apply: ${detected.summary}`);
132
+ continue;
133
+ }
134
+ try {
135
+ const result = await task.apply();
136
+ const summary = result?.summary || detected.summary || '';
137
+ writeStamp(task.id, summary);
138
+ applied.push({ id: task.id, summary });
139
+ if (verbose && logger?.log) logger.log(`[reconcile:${task.id}] ${summary}`);
140
+ } catch (err) {
141
+ skipped.push({ id: task.id, reason: `apply-error:${err.message}` });
142
+ if (verbose && logger?.error) logger.error(`[reconcile:${task.id}] failed: ${err.message}`);
143
+ }
144
+ }
145
+ return { applied, skipped };
146
+ }
147
+
148
+ export async function runReconciliation(id, { dryRun = false } = {}) {
149
+ const task = RECONCILIATIONS.find((x) => x.id === id);
150
+ if (!task) {
151
+ return { ok: false, reason: `Unknown reconciliation: ${id}`, available: RECONCILIATIONS.map((x) => x.id) };
152
+ }
153
+ const detected = await safeDetect(task);
154
+ if (detected.error) return { ok: false, reason: `detect-error:${detected.error}` };
155
+ if (!detected.needsRepair) {
156
+ return { ok: true, ran: false, reason: 'no-op', summary: detected.summary || 'Nothing to reconcile.' };
157
+ }
158
+ if (dryRun) return { ok: true, ran: false, dryRun: true, summary: detected.summary };
159
+ try {
160
+ const result = await task.apply();
161
+ const summary = result?.summary || detected.summary || '';
162
+ writeStamp(id, summary);
163
+ return { ok: true, ran: true, summary };
164
+ } catch (err) {
165
+ return { ok: false, ran: false, reason: `apply-error:${err.message}` };
166
+ }
167
+ }
168
+
169
+ export function reconcileStampPath() {
170
+ return stampPath();
171
+ }
@@ -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
+ };