@geraldmaron/construct 1.3.0 → 1.3.1
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/lib/doctor/cli.mjs
CHANGED
|
@@ -101,16 +101,26 @@ export async function runCli(args) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
if (sub === 'consistency') {
|
|
104
|
+
const strict = args.some((a) => a === '--strict' || a === '--all' || a === '--debug');
|
|
104
105
|
const { runAllChecks } = await import('./watchers/consistency.mjs');
|
|
105
106
|
const result = await runAllChecks();
|
|
106
107
|
const blocking = result.findings.filter((f) => f.severity === 'blocking');
|
|
107
108
|
const warnings = result.findings.filter((f) => f.severity === 'warning');
|
|
108
109
|
|
|
110
|
+
// Default output carries only user-actionable signal; package/maintainer drift
|
|
111
|
+
// (tier 'internal') is gated behind --strict so a clean install reads clean.
|
|
112
|
+
|
|
113
|
+
const visibleWarnings = strict ? warnings : warnings.filter((f) => f.tier !== 'internal');
|
|
114
|
+
const internalWarnings = warnings.filter((f) => f.tier === 'internal');
|
|
115
|
+
|
|
109
116
|
for (const p of result.passed) console.log(` ✓ ${p.category.padEnd(18)} ${p.summary}`);
|
|
110
|
-
for (const w of
|
|
117
|
+
for (const w of visibleWarnings) console.log(` ⚠ ${w.category.padEnd(18)} ${w.summary}`);
|
|
111
118
|
for (const b of blocking) console.log(` ✗ ${b.category.padEnd(18)} ${b.summary}`);
|
|
112
119
|
|
|
113
|
-
console.log(`\n${result.passed.length} category(s) clean, ${
|
|
120
|
+
console.log(`\n${result.passed.length} category(s) clean, ${visibleWarnings.length} warning(s), ${blocking.length} blocking finding(s)`);
|
|
121
|
+
if (!strict && internalWarnings.length > 0) {
|
|
122
|
+
console.log(` (+${internalWarnings.length} package-internal diagnostic(s) — run \`construct doctor consistency --strict\` to view)`);
|
|
123
|
+
}
|
|
114
124
|
return blocking.length > 0 ? 1 : 0;
|
|
115
125
|
}
|
|
116
126
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* directory listings, no network.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { existsSync, readFileSync,
|
|
22
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
23
23
|
import { loadRegistry } from '../../registry/loader.mjs';
|
|
24
24
|
import { join, dirname, resolve } from 'node:path';
|
|
25
25
|
import os from 'node:os';
|
|
@@ -97,15 +97,32 @@ export async function runAllChecks({ repoRoot = REPO_ROOT } = {}) {
|
|
|
97
97
|
return { findings, passed };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Drift categories split into two operator tiers. `actionable` findings are
|
|
101
|
+
// project state a user can fix and surface by default. `internal` findings are
|
|
102
|
+
// package/maintainer diagnostics about Construct's own registry and MCP wiring —
|
|
103
|
+
// never user-actionable in a consumer project (this watcher resolves REPO_ROOT to
|
|
104
|
+
// the installed package), so they stay behind `doctor consistency --strict`.
|
|
105
|
+
|
|
106
|
+
const CATEGORY_TIERS = {
|
|
107
|
+
'mcp-drift': 'internal',
|
|
108
|
+
'roles-drift': 'internal',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function tierFor(category) {
|
|
112
|
+
return CATEGORY_TIERS[category] || 'actionable';
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
function collect(result, findings, passed, defaultCategory) {
|
|
101
116
|
if (result.violations.length === 0) {
|
|
102
|
-
passed.push({ category: defaultCategory, summary: result.summary });
|
|
117
|
+
passed.push({ category: defaultCategory, summary: result.summary, tier: tierFor(defaultCategory) });
|
|
103
118
|
return;
|
|
104
119
|
}
|
|
105
120
|
for (const v of result.violations) {
|
|
121
|
+
const category = v.category || defaultCategory;
|
|
106
122
|
findings.push({
|
|
107
|
-
category
|
|
123
|
+
category,
|
|
108
124
|
severity: v.severity || 'warning',
|
|
125
|
+
tier: v.tier || tierFor(category),
|
|
109
126
|
summary: v.summary,
|
|
110
127
|
target: v.target || null,
|
|
111
128
|
details: v.details || null,
|
|
@@ -184,38 +201,51 @@ function checkMcpDrift({ repoRoot }) {
|
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
const serverSource = readFileSync(serverPath, 'utf8');
|
|
187
|
-
const dispatchedTools = new Set();
|
|
188
|
-
for (const m of serverSource.matchAll(/name === ['"]([a-z][a-z0-9_]*)['"]/g)) {
|
|
189
|
-
dispatchedTools.add(m[1]);
|
|
190
|
-
}
|
|
191
204
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
// Candidate tool handlers are exactly the identifiers server.mjs imports from
|
|
206
|
+
// ./tools/*.mjs. Tool modules also export private helpers (exec, readJson) that
|
|
207
|
+
// are never MCP tools; scanning every `export function` swept those into the
|
|
208
|
+
// signal. Scoping to the server's own imports is the real contract surface.
|
|
209
|
+
|
|
210
|
+
const importedHandlers = new Set();
|
|
211
|
+
for (const m of serverSource.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]\.\/tools\/[^'"]+['"]/g)) {
|
|
212
|
+
for (const ident of m[1].split(',')) {
|
|
213
|
+
const local = ident.trim().split(/\s+as\s+/).pop().trim();
|
|
214
|
+
if (local) importedHandlers.add(local);
|
|
199
215
|
}
|
|
200
216
|
}
|
|
201
217
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
// A handler is wired when the dispatcher invokes it, regardless of the
|
|
219
|
+
// registered tool name. Handlers follow xxxTool→'xxx' and construct_xxx naming
|
|
220
|
+
// conventions, so matching exported names against `name === '<tool>'` strings is
|
|
221
|
+
// unreliable; matching the invoked identifier is convention-agnostic.
|
|
222
|
+
|
|
223
|
+
const invoked = new Set();
|
|
224
|
+
for (const m of serverSource.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
225
|
+
invoked.add(m[1]);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const handler of importedHandlers) {
|
|
229
|
+
const snake = camelToSnake(handler);
|
|
230
|
+
if (KNOWN_NON_DISPATCH_TOOLS.has(handler) || KNOWN_NON_DISPATCH_TOOLS.has(snake)) continue;
|
|
231
|
+
if (!invoked.has(handler)) {
|
|
205
232
|
violations.push({
|
|
206
233
|
category: 'mcp-drift',
|
|
207
234
|
severity: 'warning',
|
|
208
|
-
|
|
209
|
-
|
|
235
|
+
tier: 'internal',
|
|
236
|
+
target: snake,
|
|
237
|
+
summary: `MCP tool handler imported but never dispatched: ${handler}`,
|
|
210
238
|
});
|
|
211
239
|
}
|
|
212
240
|
}
|
|
213
241
|
|
|
214
|
-
return { summary: `mcp: ${
|
|
242
|
+
return { summary: `mcp: ${importedHandlers.size} handlers, ${violations.length} drift`, violations };
|
|
215
243
|
}
|
|
216
244
|
|
|
245
|
+
// Handlers the server may import for re-export or test surface but intentionally
|
|
246
|
+
// never dispatches. Matched by camelCase identifier or snake_case form.
|
|
247
|
+
|
|
217
248
|
const KNOWN_NON_DISPATCH_TOOLS = new Set([
|
|
218
|
-
// Internal helpers exported for tests but never wired through MCP.
|
|
219
249
|
'workflow_status_bound',
|
|
220
250
|
'create_needs_main_input_packet',
|
|
221
251
|
]);
|
|
@@ -263,21 +293,28 @@ function personaOwnersFromRegistry(registry) {
|
|
|
263
293
|
owners.get(personaId).add(owner);
|
|
264
294
|
}
|
|
265
295
|
|
|
296
|
+
// One registry entity owns a persona once. A specialist's id and its own name
|
|
297
|
+
// normalize to the same persona by design (cx-architect / architect), so they
|
|
298
|
+
// must share a single owner token keyed on the entity — otherwise every
|
|
299
|
+
// specialist self-collides and reports a spurious "ambiguous" drift. Genuine
|
|
300
|
+
// ambiguity (two distinct specialists, or a specialist and the orchestrator,
|
|
301
|
+
// normalizing to the same persona) still yields owners.size > 1.
|
|
302
|
+
|
|
266
303
|
for (const [specId, spec] of Object.entries(registry?.specialists || {})) {
|
|
267
304
|
addOwner(specId, `specialist:${specId}`);
|
|
268
|
-
addOwner(spec?.name, `specialist:${specId}
|
|
305
|
+
addOwner(spec?.name, `specialist:${specId}`);
|
|
269
306
|
}
|
|
270
307
|
|
|
271
308
|
if (Array.isArray(registry?.specialists)) {
|
|
272
309
|
for (const spec of registry.specialists) {
|
|
273
310
|
const owner = `specialist:${spec?.id || spec?.name || 'unknown'}`;
|
|
274
311
|
addOwner(spec?.id, owner);
|
|
275
|
-
addOwner(spec?.name,
|
|
312
|
+
addOwner(spec?.name, owner);
|
|
276
313
|
}
|
|
277
314
|
}
|
|
278
315
|
|
|
279
|
-
addOwner(registry?.orchestrator?.id, 'orchestrator
|
|
280
|
-
addOwner(registry?.orchestrator?.name, 'orchestrator
|
|
316
|
+
addOwner(registry?.orchestrator?.id, 'orchestrator');
|
|
317
|
+
addOwner(registry?.orchestrator?.name, 'orchestrator');
|
|
281
318
|
return owners;
|
|
282
319
|
}
|
|
283
320
|
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
} from './tools/storage.mjs';
|
|
39
39
|
import {
|
|
40
40
|
listSkills, getSkill, searchSkills, getTemplate, listTemplates,
|
|
41
|
-
agentContract, brokerCheck, orchestrationPolicy, workerRun, listTeams,
|
|
41
|
+
agentContract, brokerCheck, orchestrationPolicy, workerRun, listTeams, suggestSkillsTool,
|
|
42
42
|
} from './tools/skills.mjs';
|
|
43
43
|
import {
|
|
44
44
|
workflowInit, workflowAddTask, workflowUpdateTask,
|
package/package.json
CHANGED
|
@@ -1002,13 +1002,20 @@ ${buildPrompt(entry, allEntries, "claude")}
|
|
|
1002
1002
|
* Rewrite the home-mode hook command pattern
|
|
1003
1003
|
* node "$HOME/.config/construct/lib/hooks/<name>.mjs"
|
|
1004
1004
|
* into the project-portable form
|
|
1005
|
-
* node
|
|
1005
|
+
* node "${CLAUDE_PROJECT_DIR:-.}/.construct/run.mjs" hook <name>
|
|
1006
1006
|
* so the resulting settings.json works on any clone where the project ships
|
|
1007
1007
|
* the .construct/ launcher (committed by `npm install`'s postinstall or by
|
|
1008
1008
|
* `construct init`). The launcher resolves Construct via node_modules → npx
|
|
1009
1009
|
* → globally-installed CLI → cached binary → docker, in that order, so it
|
|
1010
1010
|
* works for non-Node ecosystems too. Other commands (inline node -e
|
|
1011
1011
|
* snippets, npx block-no-verify@…) are left untouched.
|
|
1012
|
+
*
|
|
1013
|
+
* The `${CLAUDE_PROJECT_DIR:-.}` anchor matters: Claude Code invokes hooks with
|
|
1014
|
+
* a working directory that is not guaranteed to be the project root (observed:
|
|
1015
|
+
* $HOME), and a bare relative `.construct/run.mjs` then fails with MODULE_NOT_FOUND
|
|
1016
|
+
* at node:internal/modules/cjs/loader. CLAUDE_PROJECT_DIR is the project root Claude
|
|
1017
|
+
* Code exports to every hook (same var lib/hooks/*.mjs already read); the `:-.`
|
|
1018
|
+
* fallback preserves the prior relative behavior wherever the var is unset.
|
|
1012
1019
|
*/
|
|
1013
1020
|
function makeHooksPortable(hooksJson) {
|
|
1014
1021
|
// Operate on the in-memory object so we don't fight JSON string escaping.
|
|
@@ -1017,7 +1024,7 @@ function makeHooksPortable(hooksJson) {
|
|
|
1017
1024
|
const m = cmd.match(/^node\s+"?\$HOME\/\.config\/construct\/lib\/hooks\/([a-z0-9-]+)\.mjs"?\s*(.*)$/);
|
|
1018
1025
|
if (!m) return cmd;
|
|
1019
1026
|
const [, name, rest] = m;
|
|
1020
|
-
return `node
|
|
1027
|
+
return `node "\${CLAUDE_PROJECT_DIR:-.}/.construct/run.mjs" hook ${name}${rest ? ' ' + rest.trim() : ''}`;
|
|
1021
1028
|
};
|
|
1022
1029
|
|
|
1023
1030
|
const walk = (node) => {
|