@geraldmaron/construct 1.0.3 → 1.0.5
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 +51 -42
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +279 -4
- package/commands/understand/research.md +5 -3
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +3 -4
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +52 -2
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/flavors/loader.mjs +136 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +25 -2
- package/lib/init-update.mjs +246 -131
- package/lib/intake/classify.mjs +61 -183
- package/lib/intake/prepare.mjs +7 -0
- package/lib/intake/tables/creative.mjs +94 -0
- package/lib/intake/tables/operations.mjs +85 -0
- package/lib/intake/tables/research.mjs +85 -0
- package/lib/intake/tables/rnd.mjs +175 -0
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/research-store.mjs +109 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/observation-store.mjs +19 -0
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +121 -21
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/worker/trace.mjs +17 -27
- package/package.json +10 -2
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/rules/common/research.md +44 -12
- package/scripts/sync-agents.mjs +11 -0
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/architect.ai-systems.md +4 -2
- package/skills/roles/architect.data.md +4 -2
- package/skills/roles/architect.enterprise.md +4 -2
- package/skills/roles/architect.integration.md +4 -2
- package/skills/roles/architect.md +7 -5
- package/skills/roles/architect.platform.md +4 -2
- package/skills/roles/data-analyst.experiment.md +4 -2
- package/skills/roles/data-analyst.md +9 -7
- package/skills/roles/data-analyst.product-intelligence.md +5 -3
- package/skills/roles/data-analyst.product.md +4 -2
- package/skills/roles/data-analyst.telemetry.md +4 -2
- package/skills/roles/data-engineer.pipeline.md +4 -2
- package/skills/roles/data-engineer.vector-retrieval.md +4 -2
- package/skills/roles/data-engineer.warehouse.md +4 -2
- package/skills/roles/debugger.md +7 -5
- package/skills/roles/designer.accessibility.md +4 -2
- package/skills/roles/designer.md +10 -8
- package/skills/roles/engineer.ai.md +4 -2
- package/skills/roles/engineer.data.md +5 -3
- package/skills/roles/engineer.md +14 -12
- package/skills/roles/engineer.platform.md +5 -3
- package/skills/roles/operator.docs.md +6 -4
- package/skills/roles/operator.md +6 -4
- package/skills/roles/operator.release.md +4 -2
- package/skills/roles/operator.sre.md +5 -3
- package/skills/roles/orchestrator.md +5 -3
- package/skills/roles/product-manager.ai-product.md +4 -2
- package/skills/roles/product-manager.business-strategy.md +4 -2
- package/skills/roles/product-manager.enterprise.md +4 -2
- package/skills/roles/product-manager.growth.md +4 -2
- package/skills/roles/product-manager.md +6 -4
- package/skills/roles/product-manager.platform.md +4 -2
- package/skills/roles/product-manager.product.md +4 -2
- package/skills/roles/qa.ai-eval.md +4 -2
- package/skills/roles/qa.api-contract.md +4 -2
- package/skills/roles/qa.data-pipeline.md +4 -2
- package/skills/roles/qa.md +7 -5
- package/skills/roles/qa.test-automation.md +5 -3
- package/skills/roles/qa.web-ui.md +4 -2
- package/skills/roles/researcher.explorer.md +4 -2
- package/skills/roles/researcher.md +35 -20
- package/skills/roles/researcher.ux.md +4 -2
- package/skills/roles/reviewer.devil-advocate.md +4 -2
- package/skills/roles/reviewer.evaluator.md +4 -2
- package/skills/roles/reviewer.md +14 -12
- package/skills/roles/reviewer.trace.md +4 -2
- package/skills/roles/security.ai.md +4 -2
- package/skills/roles/security.appsec.md +4 -2
- package/skills/roles/security.cloud.md +4 -2
- package/skills/roles/security.legal-compliance.md +4 -2
- package/skills/roles/security.md +7 -5
- package/skills/roles/security.privacy.md +4 -2
- package/skills/roles/security.supply-chain.md +4 -2
- package/skills/routing.md +8 -1
- package/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +6 -6
|
@@ -324,6 +324,14 @@ export function classifyDataEngineerFlavor(request = '') {
|
|
|
324
324
|
return null;
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
export function classifyEngineerFlavor(request = '') {
|
|
328
|
+
const text = String(request).toLowerCase();
|
|
329
|
+
if (containsAny(text, ['llm', 'agent', 'prompt', 'eval', 'hallucination', 'rag', 'model behavior', 'model routing'])) return 'ai';
|
|
330
|
+
if (containsAny(text, ['vector', 'embedding', 'retrieval', 'warehouse', 'etl', 'elt', 'pipeline', 'streaming', 'data contract'])) return 'data';
|
|
331
|
+
if (containsAny(text, ['ci', 'build', 'deploy', 'docker', 'kubernetes', 'terraform', 'helm', 'release pipeline', 'platform tooling', 'developer experience'])) return 'platform';
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
327
335
|
export function isDataAnalysisRequest(request = '') {
|
|
328
336
|
return Boolean(classifyDataAnalystFlavor(request));
|
|
329
337
|
}
|
|
@@ -406,6 +414,7 @@ export function isVisualDeliverableRequest(request = '') {
|
|
|
406
414
|
|
|
407
415
|
export function classifyRoleFlavors(request = '') {
|
|
408
416
|
return {
|
|
417
|
+
engineer: classifyEngineerFlavor(request),
|
|
409
418
|
architect: classifyArchitectFlavor(request),
|
|
410
419
|
productManager: isProductIntelligenceRequest(request) ? classifyProductManagerFlavor(request) : null,
|
|
411
420
|
qa: classifyQaFlavor(request),
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/outcomes/aggregate.mjs — Per-role rolling success-rate summary.
|
|
3
|
+
*
|
|
4
|
+
* Reads `.cx/outcomes/<role>.jsonl` files and produces `_summary.json`:
|
|
5
|
+
*
|
|
6
|
+
* { generatedAt, roles: { <role>: { count, success, successRate,
|
|
7
|
+
* p50DurationMs, last30: { count, successRate } } } }
|
|
8
|
+
*
|
|
9
|
+
* The classifier reads `successRate` to apply a tie-breaker soft re-rank
|
|
10
|
+
* (capped at ±0.05 in lib/intake/classify.mjs so it cannot invert the
|
|
11
|
+
* primary keyword signal).
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { listOutcomes } from './record.mjs';
|
|
16
|
+
|
|
17
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
const LAST_WINDOW_DAYS = 30;
|
|
19
|
+
|
|
20
|
+
function median(nums) {
|
|
21
|
+
if (nums.length === 0) return null;
|
|
22
|
+
const sorted = [...nums].sort((a, b) => a - b);
|
|
23
|
+
const mid = Math.floor(sorted.length / 2);
|
|
24
|
+
return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rollupRole(outcomes) {
|
|
28
|
+
const total = outcomes.length;
|
|
29
|
+
if (total === 0) return null;
|
|
30
|
+
const success = outcomes.filter((o) => o.success === true).length;
|
|
31
|
+
const durations = outcomes
|
|
32
|
+
.map((o) => o.durationMs)
|
|
33
|
+
.filter((d) => Number.isFinite(d) && d > 0);
|
|
34
|
+
|
|
35
|
+
const cutoff = Date.now() - LAST_WINDOW_DAYS * DAY_MS;
|
|
36
|
+
const recent = outcomes.filter((o) => Date.parse(o.ts) >= cutoff);
|
|
37
|
+
const recentSuccess = recent.filter((o) => o.success === true).length;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
count: total,
|
|
41
|
+
success,
|
|
42
|
+
successRate: total > 0 ? Number((success / total).toFixed(3)) : 0,
|
|
43
|
+
p50DurationMs: median(durations),
|
|
44
|
+
last30: {
|
|
45
|
+
count: recent.length,
|
|
46
|
+
successRate: recent.length > 0 ? Number((recentSuccess / recent.length).toFixed(3)) : 0,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List every role with outcome data in this project. Returns role ids.
|
|
53
|
+
*/
|
|
54
|
+
export function listRolesWithOutcomes(cwd) {
|
|
55
|
+
const dir = path.join(cwd, '.cx', 'outcomes');
|
|
56
|
+
if (!fs.existsSync(dir)) return [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const f of fs.readdirSync(dir)) {
|
|
59
|
+
const m = /^([a-z0-9-]+)(\.\d+)?\.jsonl$/.exec(f);
|
|
60
|
+
if (m) seen.add(m[1]);
|
|
61
|
+
}
|
|
62
|
+
return Array.from(seen).sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rebuild the summary from all per-role JSONL files. Idempotent.
|
|
67
|
+
*/
|
|
68
|
+
export function aggregateOutcomes(cwd) {
|
|
69
|
+
const roles = listRolesWithOutcomes(cwd);
|
|
70
|
+
const out = { generatedAt: new Date().toISOString(), roles: {} };
|
|
71
|
+
for (const role of roles) {
|
|
72
|
+
const rollup = rollupRole(listOutcomes(cwd, role));
|
|
73
|
+
if (rollup) out.roles[role] = rollup;
|
|
74
|
+
}
|
|
75
|
+
const dir = path.join(cwd, '.cx', 'outcomes');
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, '_summary.json'), JSON.stringify(out, null, 2) + '\n');
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the cached summary without recomputing. Keeps the classifier
|
|
83
|
+
* tiebreaker on an O(1) hot path.
|
|
84
|
+
*/
|
|
85
|
+
export function readSummary(cwd) {
|
|
86
|
+
const p = path.join(cwd, '.cx', 'outcomes', '_summary.json');
|
|
87
|
+
if (!fs.existsSync(p)) return null;
|
|
88
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tiebreaker score for the classifier. Returns a number in [-0.05, 0.05]
|
|
93
|
+
* derived from the role's recent success rate. Default behavior on missing
|
|
94
|
+
* data is 0 (no influence).
|
|
95
|
+
*/
|
|
96
|
+
export function outcomeBoost(cwd, role, { cap = 0.05 } = {}) {
|
|
97
|
+
const summary = readSummary(cwd);
|
|
98
|
+
const stats = summary?.roles?.[role];
|
|
99
|
+
if (!stats || !stats.last30 || stats.last30.count < 3) return 0;
|
|
100
|
+
// Map [0.5, 1.0] success → [0, cap]; [0, 0.5] → [-cap, 0]. Linear, capped.
|
|
101
|
+
const rate = stats.last30.successRate;
|
|
102
|
+
const centered = (rate - 0.5) * 2; // [-1, 1]
|
|
103
|
+
return Math.max(-cap, Math.min(cap, centered * cap));
|
|
104
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/outcomes/record.mjs — Specialist outcome recorder.
|
|
3
|
+
*
|
|
4
|
+
* Records the result of every dispatched specialist into
|
|
5
|
+
* `.cx/outcomes/<role>.jsonl`. The aggregator (lib/outcomes/aggregate.mjs)
|
|
6
|
+
* reads these files to produce per-role rolling success rates, which the
|
|
7
|
+
* classifier consumes as a soft tiebreaker.
|
|
8
|
+
*
|
|
9
|
+
* Schema (one JSONL line per outcome):
|
|
10
|
+
* { ts, role, intakeId, profile, success, escalated, durationMs, notes,
|
|
11
|
+
* source: 'agent-tracker' | 'feedback' | 'manual' }
|
|
12
|
+
*
|
|
13
|
+
* Files rotate at 10k lines per role to keep disk bounded. The rotated file
|
|
14
|
+
* is named `<role>.<yyyymm>.jsonl`. Current is always `<role>.jsonl`.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { withFileLockSync } from '../storage/file-lock.mjs';
|
|
19
|
+
|
|
20
|
+
const ROTATE_AT = 10_000;
|
|
21
|
+
const MAX_NOTES = 500;
|
|
22
|
+
|
|
23
|
+
function safeRole(role) {
|
|
24
|
+
return String(role || 'unknown').replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 60) || 'unknown';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function outcomesDir(cwd) {
|
|
28
|
+
return path.join(cwd, '.cx', 'outcomes');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function activeFile(cwd, role) {
|
|
32
|
+
return path.join(outcomesDir(cwd), `${safeRole(role)}.jsonl`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rotatedFile(cwd, role) {
|
|
36
|
+
const stamp = new Date().toISOString().slice(0, 7).replace('-', ''); // yyyymm
|
|
37
|
+
return path.join(outcomesDir(cwd), `${safeRole(role)}.${stamp}.jsonl`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function countLines(file) {
|
|
41
|
+
try {
|
|
42
|
+
const buf = fs.readFileSync(file);
|
|
43
|
+
let n = 0;
|
|
44
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) n++;
|
|
45
|
+
return n;
|
|
46
|
+
} catch { return 0; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Record a specialist outcome. Best-effort: any IO failure is swallowed and
|
|
51
|
+
* the caller proceeds. Outcomes are telemetry, never on the critical path.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} cwd - project root
|
|
54
|
+
* @param {object} payload
|
|
55
|
+
* @param {string} payload.role - specialist id (cx-engineer → 'engineer')
|
|
56
|
+
* @param {string} [payload.intakeId]
|
|
57
|
+
* @param {string} [payload.profile] - active profile id
|
|
58
|
+
* @param {boolean} payload.success
|
|
59
|
+
* @param {boolean} [payload.escalated]
|
|
60
|
+
* @param {number} [payload.durationMs]
|
|
61
|
+
* @param {string} [payload.notes]
|
|
62
|
+
* @param {string} [payload.source='manual']
|
|
63
|
+
* @returns {string|null} path to the file written, or null on failure
|
|
64
|
+
*/
|
|
65
|
+
export function recordOutcome(cwd, payload) {
|
|
66
|
+
if (!cwd || !payload || typeof payload.success !== 'boolean') return null;
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(outcomesDir(cwd), { recursive: true });
|
|
69
|
+
const file = activeFile(cwd, payload.role);
|
|
70
|
+
|
|
71
|
+
if (countLines(file) >= ROTATE_AT) {
|
|
72
|
+
try { fs.renameSync(file, rotatedFile(cwd, payload.role)); } catch { /* race */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const entry = {
|
|
76
|
+
ts: new Date().toISOString(),
|
|
77
|
+
role: safeRole(payload.role),
|
|
78
|
+
intakeId: payload.intakeId || null,
|
|
79
|
+
profile: payload.profile || null,
|
|
80
|
+
success: !!payload.success,
|
|
81
|
+
escalated: !!payload.escalated,
|
|
82
|
+
durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : null,
|
|
83
|
+
notes: payload.notes ? String(payload.notes).slice(0, MAX_NOTES) : null,
|
|
84
|
+
source: payload.source || 'manual',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
withFileLockSync(file, () => {
|
|
88
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
89
|
+
});
|
|
90
|
+
return file;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read every outcome line for a role across active + rotated files.
|
|
98
|
+
* Returns oldest-first.
|
|
99
|
+
*/
|
|
100
|
+
export function listOutcomes(cwd, role) {
|
|
101
|
+
const dir = outcomesDir(cwd);
|
|
102
|
+
if (!fs.existsSync(dir)) return [];
|
|
103
|
+
const safe = safeRole(role);
|
|
104
|
+
const files = fs.readdirSync(dir).filter((f) => f.startsWith(`${safe}.`) && f.endsWith('.jsonl'));
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const f of files.sort()) {
|
|
107
|
+
let raw = '';
|
|
108
|
+
try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
109
|
+
for (const line of raw.split('\n')) {
|
|
110
|
+
if (!line.trim()) continue;
|
|
111
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
package/lib/parity.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/parity.mjs — Cross-surface parity verifier.
|
|
3
3
|
*
|
|
4
4
|
* After `construct sync` writes adapters to multiple surfaces (Claude Code,
|
|
5
|
-
* OpenCode, Codex), the module diffs each surface's actual state against the
|
|
5
|
+
* OpenCode, Codex, Copilot, VS Code, Cursor), the module diffs each surface's actual state against the
|
|
6
6
|
* canonical `agents/registry.json`. Backs `construct doctor` for surfacing
|
|
7
7
|
* silent divergence — for instance, an agent added to the registry that
|
|
8
8
|
* never made it to OpenCode because of a sync regression.
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
* the field are mirrored everywhere.
|
|
15
15
|
*
|
|
16
16
|
* Surfaces checked:
|
|
17
|
-
* claude
|
|
18
|
-
* opencode
|
|
19
|
-
* codex
|
|
17
|
+
* claude ~/.claude/agents/*.md
|
|
18
|
+
* opencode ~/.config/opencode/opencode.json (agent table)
|
|
19
|
+
* codex ~/.codex/agents/*.toml
|
|
20
|
+
* copilot ~/.github/prompts/*.prompt.md
|
|
21
|
+
* vscode VS Code user settings (github.copilot.mcpServers)
|
|
22
|
+
* cursor ~/.cursor/mcp.json
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import fs from 'node:fs';
|
|
@@ -59,16 +62,15 @@ function diffSets(expected, actual) {
|
|
|
59
62
|
return { missing, extra };
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
if (!fs.existsSync(dir)) return { surface: 'claude', status: 'absent', dir };
|
|
65
|
-
const expected = entriesForSurface(registry, 'claude');
|
|
65
|
+
function checkFileSurface({ surface, kind, dir, extension, expected }) {
|
|
66
|
+
if (!fs.existsSync(dir)) return { surface, kind, status: 'absent', dir };
|
|
66
67
|
const actual = fs.readdirSync(dir)
|
|
67
|
-
.filter((name) => name.endsWith(
|
|
68
|
-
.map((name) => name.
|
|
68
|
+
.filter((name) => name.endsWith(extension))
|
|
69
|
+
.map((name) => name.slice(0, -extension.length));
|
|
69
70
|
const { missing, extra } = diffSets(expected, actual);
|
|
70
71
|
return {
|
|
71
|
-
surface
|
|
72
|
+
surface,
|
|
73
|
+
kind,
|
|
72
74
|
status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
|
|
73
75
|
dir,
|
|
74
76
|
expectedCount: expected.length,
|
|
@@ -78,20 +80,56 @@ function checkClaude(registry, { homeDir = os.homedir() } = {}) {
|
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
function getVSCodeSettingsPaths(homeDir) {
|
|
84
|
+
const platform = os.platform();
|
|
85
|
+
if (platform === 'darwin') {
|
|
86
|
+
return [
|
|
87
|
+
path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
|
|
88
|
+
path.join(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
if (platform === 'linux') {
|
|
92
|
+
return [
|
|
93
|
+
path.join(homeDir, '.config', 'Code', 'User', 'settings.json'),
|
|
94
|
+
path.join(homeDir, '.config', 'Code - Insiders', 'User', 'settings.json'),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
if (platform === 'win32') {
|
|
98
|
+
const appData = process.env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming');
|
|
99
|
+
return [
|
|
100
|
+
path.join(appData, 'Code', 'User', 'settings.json'),
|
|
101
|
+
path.join(appData, 'Code - Insiders', 'User', 'settings.json'),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function checkClaude(registry, { homeDir = os.homedir() } = {}) {
|
|
108
|
+
const expected = entriesForSurface(registry, 'claude');
|
|
109
|
+
return checkFileSurface({
|
|
110
|
+
surface: 'claude',
|
|
111
|
+
kind: 'agents',
|
|
112
|
+
dir: path.join(homeDir, '.claude', 'agents'),
|
|
113
|
+
extension: '.md',
|
|
114
|
+
expected,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
|
|
82
119
|
const file = path.join(homeDir, '.config', 'opencode', 'opencode.json');
|
|
83
|
-
if (!fs.existsSync(file)) return { surface: 'opencode', status: 'absent', file };
|
|
120
|
+
if (!fs.existsSync(file)) return { surface: 'opencode', kind: 'agents', status: 'absent', file };
|
|
84
121
|
let config;
|
|
85
122
|
try {
|
|
86
123
|
config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
87
124
|
} catch (err) {
|
|
88
|
-
return { surface: 'opencode', status: 'unreadable', file, error: err.message };
|
|
125
|
+
return { surface: 'opencode', kind: 'agents', status: 'unreadable', file, error: err.message };
|
|
89
126
|
}
|
|
90
127
|
const expected = entriesForSurface(registry, 'opencode');
|
|
91
128
|
const actual = Object.keys(config.agent || config.agents || {});
|
|
92
129
|
const { missing, extra } = diffSets(expected, actual);
|
|
93
130
|
return {
|
|
94
131
|
surface: 'opencode',
|
|
132
|
+
kind: 'agents',
|
|
95
133
|
status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
|
|
96
134
|
file,
|
|
97
135
|
expectedCount: expected.length,
|
|
@@ -102,17 +140,76 @@ function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
|
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
function checkCodex(registry, { homeDir = os.homedir() } = {}) {
|
|
105
|
-
const dir = path.join(homeDir, '.codex', 'agents');
|
|
106
|
-
if (!fs.existsSync(dir)) return { surface: 'codex', status: 'absent', dir };
|
|
107
143
|
const expected = entriesForSurface(registry, 'codex');
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
144
|
+
return checkFileSurface({
|
|
145
|
+
surface: 'codex',
|
|
146
|
+
kind: 'agents',
|
|
147
|
+
dir: path.join(homeDir, '.codex', 'agents'),
|
|
148
|
+
extension: '.toml',
|
|
149
|
+
expected,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function checkCopilot(registry, { homeDir = os.homedir() } = {}) {
|
|
154
|
+
// Use the same helper as Claude/OpenCode/Codex so all surfaces apply the
|
|
155
|
+
// platform-filter rules identically. The previous implementation dropped
|
|
156
|
+
// entries with internal:true, which produced false drift because the sync
|
|
157
|
+
// script writes every specialist to ~/.github/prompts regardless of the
|
|
158
|
+
// internal flag.
|
|
159
|
+
const expected = entriesForSurface(registry, 'copilot');
|
|
160
|
+
return checkFileSurface({
|
|
161
|
+
surface: 'copilot',
|
|
162
|
+
kind: 'prompts',
|
|
163
|
+
dir: path.join(homeDir, '.github', 'prompts'),
|
|
164
|
+
extension: '.prompt.md',
|
|
165
|
+
expected,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkVSCode(registry, { homeDir = os.homedir() } = {}) {
|
|
170
|
+
const paths = getVSCodeSettingsPaths(homeDir).filter((settingsPath) => fs.existsSync(settingsPath));
|
|
171
|
+
if (paths.length === 0) return { surface: 'vscode', kind: 'mcps', status: 'absent', paths };
|
|
172
|
+
const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
|
|
173
|
+
const actualSet = new Set();
|
|
174
|
+
for (const settingsPath of paths) {
|
|
175
|
+
try {
|
|
176
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
177
|
+
for (const id of Object.keys(settings['github.copilot.mcpServers'] ?? {})) actualSet.add(id);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { surface: 'vscode', kind: 'mcps', status: 'unreadable', file: settingsPath, error: err.message };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const actual = [...actualSet].sort();
|
|
111
183
|
const { missing, extra } = diffSets(expected, actual);
|
|
112
184
|
return {
|
|
113
|
-
surface: '
|
|
185
|
+
surface: 'vscode',
|
|
186
|
+
kind: 'mcps',
|
|
114
187
|
status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
|
|
115
|
-
|
|
188
|
+
paths,
|
|
189
|
+
expectedCount: expected.length,
|
|
190
|
+
actualCount: actual.length,
|
|
191
|
+
missing,
|
|
192
|
+
extra,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkCursor(registry, { homeDir = os.homedir() } = {}) {
|
|
197
|
+
const file = path.join(homeDir, '.cursor', 'mcp.json');
|
|
198
|
+
if (!fs.existsSync(file)) return { surface: 'cursor', kind: 'mcps', status: 'absent', file };
|
|
199
|
+
let config;
|
|
200
|
+
try {
|
|
201
|
+
config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { surface: 'cursor', kind: 'mcps', status: 'unreadable', file, error: err.message };
|
|
204
|
+
}
|
|
205
|
+
const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
|
|
206
|
+
const actual = Object.keys(config.mcpServers ?? {});
|
|
207
|
+
const { missing, extra } = diffSets(expected, actual);
|
|
208
|
+
return {
|
|
209
|
+
surface: 'cursor',
|
|
210
|
+
kind: 'mcps',
|
|
211
|
+
status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
|
|
212
|
+
file,
|
|
116
213
|
expectedCount: expected.length,
|
|
117
214
|
actualCount: actual.length,
|
|
118
215
|
missing,
|
|
@@ -130,13 +227,16 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
|
|
|
130
227
|
checkClaude(registry, { homeDir }),
|
|
131
228
|
checkOpenCode(registry, { homeDir }),
|
|
132
229
|
checkCodex(registry, { homeDir }),
|
|
230
|
+
checkCopilot(registry, { homeDir }),
|
|
231
|
+
checkVSCode(registry, { homeDir }),
|
|
232
|
+
checkCursor(registry, { homeDir }),
|
|
133
233
|
];
|
|
134
234
|
|
|
135
235
|
const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent');
|
|
136
236
|
const summary = surfaces.map((s) => {
|
|
137
237
|
if (s.status === 'absent') return `${s.surface}: not installed`;
|
|
138
238
|
if (s.status === 'unreadable') return `${s.surface}: unreadable (${s.error})`;
|
|
139
|
-
if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount})`;
|
|
239
|
+
if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount} ${s.kind})`;
|
|
140
240
|
const parts = [];
|
|
141
241
|
if (s.missing.length) parts.push(`missing: ${s.missing.join(', ')}`);
|
|
142
242
|
if (s.extra.length) parts.push(`extra: ${s.extra.join(', ')}`);
|