@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.
@@ -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 warnings) console.log(` ⚠ ${w.category.padEnd(18)} ${w.summary}`);
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, ${warnings.length} warning(s), ${blocking.length} blocking finding(s)`);
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, readdirSync, statSync } from 'node:fs';
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: v.category || defaultCategory,
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
- const exportedTools = new Set();
193
- const entries = readdirSync(toolsDir, { withFileTypes: true });
194
- for (const entry of entries) {
195
- if (!entry.isFile() || !entry.name.endsWith('.mjs')) continue;
196
- const src = readFileSync(join(toolsDir, entry.name), 'utf8');
197
- for (const m of src.matchAll(/export (?:async )?function (\w+)/g)) {
198
- exportedTools.add(camelToSnake(m[1]));
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
- for (const tool of exportedTools) {
203
- if (KNOWN_NON_DISPATCH_TOOLS.has(tool)) continue;
204
- if (!dispatchedTools.has(tool)) {
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
- target: tool,
209
- summary: `MCP tool exported but not dispatched: ${tool}`,
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: ${dispatchedTools.size} dispatched, ${exportedTools.size} exported, ${violations.length} drift`, violations };
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}:name`);
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, `${owner}:name`);
312
+ addOwner(spec?.name, owner);
276
313
  }
277
314
  }
278
315
 
279
- addOwner(registry?.orchestrator?.id, 'orchestrator:id');
280
- addOwner(registry?.orchestrator?.name, 'orchestrator:name');
316
+ addOwner(registry?.orchestrator?.id, 'orchestrator');
317
+ addOwner(registry?.orchestrator?.name, 'orchestrator');
281
318
  return owners;
282
319
  }
283
320
 
@@ -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, getTeam, suggestSkillsTool,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -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 .construct/run.mjs hook <name>
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 .construct/run.mjs hook ${name}${rest ? ' ' + rest.trim() : ''}`;
1027
+ return `node "\${CLAUDE_PROJECT_DIR:-.}/.construct/run.mjs" hook ${name}${rest ? ' ' + rest.trim() : ''}`;
1021
1028
  };
1022
1029
 
1023
1030
  const walk = (node) => {