@geraldmaron/construct 1.0.15 → 1.0.17

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 (198) hide show
  1. package/README.md +19 -3
  2. package/bin/construct +376 -98
  3. package/bin/construct-postinstall.mjs +17 -2
  4. package/db/schema/010_cx_scores.sql +51 -0
  5. package/lib/cli-commands.mjs +333 -147
  6. package/lib/contracts/validate.mjs +20 -10
  7. package/lib/contracts/violation-log.mjs +27 -12
  8. package/lib/embedded-contract/audit.mjs +52 -0
  9. package/lib/embedded-contract/capability.mjs +179 -0
  10. package/lib/embedded-contract/contract-version.mjs +39 -0
  11. package/lib/embedded-contract/envelope.mjs +70 -0
  12. package/lib/embedded-contract/index.mjs +71 -0
  13. package/lib/embedded-contract/ingest.mjs +77 -0
  14. package/lib/embedded-contract/model-resolve.mjs +186 -0
  15. package/lib/embedded-contract/redaction.mjs +91 -0
  16. package/lib/embedded-contract/role-facts.mjs +66 -0
  17. package/lib/embedded-contract/triage.mjs +134 -0
  18. package/lib/embedded-contract/workflow-defs.mjs +125 -0
  19. package/lib/embedded-contract/workflow-invoke.mjs +218 -0
  20. package/lib/hooks/config-protection.mjs +12 -5
  21. package/lib/hooks/stop-notify.mjs +7 -0
  22. package/lib/init-unified.mjs +36 -26
  23. package/lib/install/first-invocation.mjs +5 -1
  24. package/lib/intake/classify.mjs +6 -0
  25. package/lib/intake/prepare.mjs +22 -4
  26. package/lib/intake/tables/rnd.mjs +33 -0
  27. package/lib/integrations/intake-integrations.mjs +28 -2
  28. package/lib/mcp/server.mjs +69 -0
  29. package/lib/mcp/tools/embedded-contract.mjs +77 -0
  30. package/lib/mcp/tools/telemetry.mjs +30 -0
  31. package/lib/model-router.mjs +40 -0
  32. package/lib/op-log.mjs +61 -0
  33. package/lib/orchestration-policy.mjs +15 -0
  34. package/lib/roles/catalog.mjs +26 -95
  35. package/lib/roles/gateway.mjs +30 -1
  36. package/lib/scheduler/index.mjs +24 -4
  37. package/lib/server/insights.mjs +12 -0
  38. package/lib/service-manager.mjs +33 -11
  39. package/lib/setup.mjs +73 -10
  40. package/lib/update.mjs +31 -3
  41. package/lib/upgrade.mjs +31 -4
  42. package/lib/validators/skills.mjs +21 -0
  43. package/package.json +9 -3
  44. package/personas/construct.md +2 -0
  45. package/platforms/claude/CLAUDE.md +43 -15
  46. package/scripts/sync-specialists.mjs +32 -5
  47. package/skills/ai/agent-dev.md +2 -0
  48. package/skills/ai/llm-security.md +2 -0
  49. package/skills/ai/ml-ops.md +2 -0
  50. package/skills/ai/orchestration-workflow.md +2 -0
  51. package/skills/ai/prompt-and-eval.md +2 -0
  52. package/skills/ai/prompt-optimizer.md +2 -0
  53. package/skills/ai/rag-system.md +2 -0
  54. package/skills/ai/trace-triage.md +36 -0
  55. package/skills/architecture/api-design.md +2 -0
  56. package/skills/architecture/caching.md +2 -0
  57. package/skills/architecture/cloud-native.md +2 -0
  58. package/skills/architecture/message-queue.md +2 -0
  59. package/skills/architecture/security-arch.md +2 -0
  60. package/skills/compliance/ai-disclosure.md +2 -0
  61. package/skills/compliance/data-privacy.md +2 -0
  62. package/skills/compliance/license-audit.md +2 -0
  63. package/skills/compliance/regulatory-review.md +2 -0
  64. package/skills/development/cpp.md +2 -0
  65. package/skills/development/go.md +2 -0
  66. package/skills/development/java.md +2 -0
  67. package/skills/development/kotlin.md +2 -0
  68. package/skills/development/mobile-crossplatform.md +2 -0
  69. package/skills/development/python.md +2 -0
  70. package/skills/development/rust.md +2 -0
  71. package/skills/development/shell.md +2 -0
  72. package/skills/development/swift.md +2 -0
  73. package/skills/development/typescript.md +2 -0
  74. package/skills/devops/ci-cd.md +2 -0
  75. package/skills/devops/containerization.md +2 -0
  76. package/skills/devops/cost-optimization.md +2 -0
  77. package/skills/devops/data-engineering.md +2 -0
  78. package/skills/devops/database.md +2 -0
  79. package/skills/devops/dependency-management.md +2 -0
  80. package/skills/devops/devsecops.md +2 -0
  81. package/skills/devops/git-workflow.md +2 -0
  82. package/skills/devops/incident-response.md +2 -0
  83. package/skills/devops/monorepo.md +2 -0
  84. package/skills/devops/observability.md +2 -0
  85. package/skills/devops/performance.md +2 -0
  86. package/skills/devops/testing.md +2 -0
  87. package/skills/docs/adr-workflow.md +2 -0
  88. package/skills/docs/backlog-proposal-workflow.md +2 -0
  89. package/skills/docs/customer-profile-workflow.md +2 -0
  90. package/skills/docs/document-ingest-workflow.md +2 -0
  91. package/skills/docs/evidence-ingest-workflow.md +2 -0
  92. package/skills/docs/init-docs.md +2 -0
  93. package/skills/docs/init-project.md +2 -0
  94. package/skills/docs/memo-and-decision-capture.md +45 -0
  95. package/skills/docs/prd-workflow.md +2 -0
  96. package/skills/docs/prfaq-workflow.md +2 -0
  97. package/skills/docs/product-intelligence-review.md +2 -0
  98. package/skills/docs/product-intelligence-workflow.md +2 -0
  99. package/skills/docs/product-signal-workflow.md +2 -0
  100. package/skills/docs/research-workflow.md +2 -0
  101. package/skills/docs/runbook-workflow.md +2 -0
  102. package/skills/docs/strategy-workflow.md +2 -0
  103. package/skills/docs/transcript-synthesis.md +43 -0
  104. package/skills/exploration/dependency-graph-reading.md +2 -0
  105. package/skills/exploration/repo-map.md +2 -0
  106. package/skills/exploration/tracer-bullet-method.md +2 -0
  107. package/skills/exploration/unknown-codebase-onboarding.md +2 -0
  108. package/skills/frameworks/django.md +2 -0
  109. package/skills/frameworks/nextjs.md +2 -0
  110. package/skills/frameworks/react.md +2 -0
  111. package/skills/frameworks/spring-boot.md +2 -0
  112. package/skills/frontend-design/accessibility.md +2 -0
  113. package/skills/frontend-design/component-patterns.md +2 -0
  114. package/skills/frontend-design/engineering.md +2 -0
  115. package/skills/frontend-design/screen-reader-testing.md +34 -0
  116. package/skills/frontend-design/state-management.md +2 -0
  117. package/skills/frontend-design/ui-aesthetics.md +2 -0
  118. package/skills/frontend-design/ux-principles.md +2 -0
  119. package/skills/operating/change-management.md +2 -0
  120. package/skills/operating/incident-response.md +2 -0
  121. package/skills/operating/oncall-rotation.md +2 -0
  122. package/skills/operating/orchestration-reference.md +2 -0
  123. package/skills/operating/raw-data-structuring.md +44 -0
  124. package/skills/operating/unstructured-triage.md +45 -0
  125. package/skills/quality-gates/premortem.md +37 -0
  126. package/skills/quality-gates/review-work.md +2 -0
  127. package/skills/quality-gates/verify-change.md +2 -0
  128. package/skills/quality-gates/verify-module.md +2 -0
  129. package/skills/quality-gates/verify-quality.md +2 -0
  130. package/skills/quality-gates/verify-security.md +2 -0
  131. package/skills/roles/architect.ai-systems.md +2 -0
  132. package/skills/roles/architect.data.md +2 -0
  133. package/skills/roles/architect.enterprise.md +2 -0
  134. package/skills/roles/architect.integration.md +2 -0
  135. package/skills/roles/architect.md +2 -0
  136. package/skills/roles/architect.platform.md +2 -0
  137. package/skills/roles/data-analyst.experiment.md +2 -0
  138. package/skills/roles/data-analyst.md +2 -0
  139. package/skills/roles/data-analyst.product-intelligence.md +2 -0
  140. package/skills/roles/data-analyst.product.md +2 -0
  141. package/skills/roles/data-analyst.telemetry.md +2 -0
  142. package/skills/roles/data-engineer.pipeline.md +2 -0
  143. package/skills/roles/data-engineer.vector-retrieval.md +2 -0
  144. package/skills/roles/data-engineer.warehouse.md +2 -0
  145. package/skills/roles/debugger.md +2 -0
  146. package/skills/roles/designer.accessibility.md +2 -0
  147. package/skills/roles/designer.md +2 -0
  148. package/skills/roles/engineer.ai.md +2 -0
  149. package/skills/roles/engineer.data.md +2 -0
  150. package/skills/roles/engineer.md +2 -0
  151. package/skills/roles/engineer.platform.md +2 -0
  152. package/skills/roles/operator.docs.md +2 -0
  153. package/skills/roles/operator.md +2 -0
  154. package/skills/roles/operator.release.md +2 -0
  155. package/skills/roles/operator.sre.md +2 -0
  156. package/skills/roles/orchestrator.md +2 -0
  157. package/skills/roles/product-manager.ai-product.md +2 -0
  158. package/skills/roles/product-manager.business-strategy.md +2 -0
  159. package/skills/roles/product-manager.enterprise.md +2 -0
  160. package/skills/roles/product-manager.growth.md +2 -0
  161. package/skills/roles/product-manager.md +2 -0
  162. package/skills/roles/product-manager.platform.md +2 -0
  163. package/skills/roles/product-manager.product.md +2 -0
  164. package/skills/roles/qa.ai-eval.md +2 -0
  165. package/skills/roles/qa.api-contract.md +2 -0
  166. package/skills/roles/qa.data-pipeline.md +2 -0
  167. package/skills/roles/qa.md +2 -0
  168. package/skills/roles/qa.test-automation.md +2 -0
  169. package/skills/roles/qa.web-ui.md +2 -0
  170. package/skills/roles/researcher.explorer.md +2 -0
  171. package/skills/roles/researcher.md +2 -0
  172. package/skills/roles/researcher.ux.md +2 -0
  173. package/skills/roles/reviewer.devil-advocate.md +2 -0
  174. package/skills/roles/reviewer.evaluator.md +2 -0
  175. package/skills/roles/reviewer.md +2 -0
  176. package/skills/roles/reviewer.trace.md +2 -0
  177. package/skills/roles/security.ai.md +2 -0
  178. package/skills/roles/security.appsec.md +2 -0
  179. package/skills/roles/security.cloud.md +2 -0
  180. package/skills/roles/security.legal-compliance.md +2 -0
  181. package/skills/roles/security.md +2 -0
  182. package/skills/roles/security.privacy.md +2 -0
  183. package/skills/roles/security.supply-chain.md +2 -0
  184. package/skills/security/blue-team.md +2 -0
  185. package/skills/security/code-audit.md +2 -0
  186. package/skills/security/pentest.md +2 -0
  187. package/skills/security/red-team.md +2 -0
  188. package/skills/security/threat-intel.md +2 -0
  189. package/skills/security/vuln-research.md +2 -0
  190. package/skills/strategy/competitive-landscape.md +2 -0
  191. package/skills/strategy/jobs-to-be-done.md +38 -0
  192. package/skills/strategy/market-research-methods.md +2 -0
  193. package/skills/strategy/narrative-arc.md +2 -0
  194. package/skills/strategy/pricing-positioning.md +2 -0
  195. package/skills/utility/clean-code.md +2 -0
  196. package/specialists/prompts/cx-engineer.md +1 -1
  197. package/specialists/registry.json +18 -9
  198. package/specialists/role-manifests.json +1 -1
@@ -56,6 +56,7 @@ import {
56
56
  profileCreate, profileArchive, sandboxList, learningStatus,
57
57
  knowledgeGraphAsk,
58
58
  } from './tools/profile.mjs';
59
+ import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe } from './tools/embedded-contract.mjs';
59
60
 
60
61
  const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
61
62
  const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
@@ -1044,6 +1045,70 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1044
1045
  },
1045
1046
  },
1046
1047
  },
1048
+ {
1049
+ name: 'model_resolve',
1050
+ description: 'Resolve which model an embedded Construct workflow should use given the host/IDE provider context. Precedence: host model → same-provider-family fallback → Construct tier default → structured config error. Never reads or returns credential values (requiresCredential is a boolean) and never claims unverified provider health. Read-only; performs no writes.',
1051
+ inputSchema: {
1052
+ type: 'object',
1053
+ properties: {
1054
+ workflow_type: { type: 'string', description: 'Workflow type hint (e.g. evidence-ingest, prd-draft, architecture-review). Selects a tier when requested_tier is absent.' },
1055
+ requested_tier: { type: 'string', enum: ['reasoning', 'standard', 'fast'], description: 'Desired tier; overrides the workflow-type hint.' },
1056
+ host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
1057
+ host_model: { type: 'string', description: 'Model the host is currently using (e.g. anthropic/claude-sonnet-4-6).' },
1058
+ host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
1059
+ capabilities: { type: 'array', items: { type: 'string' }, description: 'Optional required capabilities; unverifiable ones are returned as warnings.' },
1060
+ allow_cross_provider_fallback: { type: 'boolean', description: 'Permit falling back outside the host provider family (default false).' },
1061
+ },
1062
+ },
1063
+ },
1064
+ {
1065
+ name: 'triage_recommend',
1066
+ description: 'Classify an artifact and return a role-aware plan (primary owner, role chain with rationale, suggested skills, evidence requirements, expected outputs, approval requirements, risks, next steps, canExecute) WITHOUT enqueuing or executing. Classification confidence is reported distinctly from any generation confidence. Read-only; performs no durable write.',
1067
+ inputSchema: {
1068
+ type: 'object',
1069
+ properties: {
1070
+ input: { type: 'string', description: 'Artifact text to classify (meeting notes, bug report, proposal, etc.). Provide this OR file_path.' },
1071
+ file_path: { type: 'string', description: 'Path to a file to extract and classify (PDF/Office via docling, audio/video via whisper, transcripts, plain text). Used when input is absent.' },
1072
+ source_path: { type: 'string', description: 'Optional filename/source hint to aid classification.' },
1073
+ artifact_type: { type: 'string', description: 'Optional artifact-type hint (advisory).' },
1074
+ domain: { type: 'string', description: 'Optional broad domain hint.' },
1075
+ desired_outcome: { type: 'string', description: 'Optional desired outcome (advisory).' },
1076
+ constraints: { type: 'array', items: { type: 'string' }, description: 'Optional constraints (advisory).' },
1077
+ available_roles: { type: 'array', items: { type: 'string' }, description: 'Restrict the plan to these role ids; dropped roles are reported as warnings.' },
1078
+ },
1079
+ },
1080
+ },
1081
+ {
1082
+ name: 'workflow_invoke',
1083
+ description: 'Invoke a named Construct workflow (roles/skills) non-interactively and return a provenanced execution plan: selected roles, rationale, applied skills, resolved model, evidence requirements, output contract, risks, and a traceId. Construct returns the orchestration plan; the host runtime performs specialist reasoning. Durable writes occur ONLY when approval_mode is allow-durable-write; proposal-only and requires-human-approval perform no durable writes.',
1084
+ inputSchema: {
1085
+ type: 'object',
1086
+ required: ['workflow_type'],
1087
+ properties: {
1088
+ workflow_type: { type: 'string', description: 'One of: evidence-ingest, proposal-review, prd-draft, architecture-review, risk-review, research-synthesis.' },
1089
+ input: { type: 'string', description: 'Artifact text the workflow operates on. Provide this OR file_path.' },
1090
+ file_path: { type: 'string', description: 'Path to a file to extract (docling/whisper/transcript) and operate on, used when input is absent.' },
1091
+ context: { type: 'object', description: 'Optional structured context; keys matching evidence requirements mark them satisfied.' },
1092
+ role_strategy: { type: 'string', enum: ['auto', 'explicit', 'constrained'], description: 'auto = default chain; explicit = use requested_roles; constrained = default chain intersected with requested_roles.' },
1093
+ requested_roles: { type: 'array', items: { type: 'string' }, description: 'Role ids for explicit/constrained strategies.' },
1094
+ approval_mode: { type: 'string', enum: ['proposal-only', 'requires-human-approval', 'allow-durable-write'], description: 'Gate for durable writes (default: the workflow type default).' },
1095
+ trace: { type: 'boolean', description: 'Emit a traceId for provenance correlation (default true).' },
1096
+ host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
1097
+ host_model: { type: 'string', description: 'Model the host uses, for model resolution.' },
1098
+ host_provider: { type: 'string', description: 'Provider family the host uses, for model resolution.' },
1099
+ },
1100
+ },
1101
+ },
1102
+ {
1103
+ name: 'capability_describe',
1104
+ description: 'Describe what this Construct install can do: versions, contract interfaces (CLI/MCP/SDK), roles, skills, workflows, schemas, models/providers, policies, telemetry posture, and plugins. Read-only and secret-free — provider entries carry env-key names and a configured boolean only, never credential values. Reads live registries so the published contract cannot drift from reality.',
1105
+ inputSchema: {
1106
+ type: 'object',
1107
+ properties: {
1108
+ root_dir: { type: 'string', description: 'Optional Construct install root (default: server toolkit dir).' },
1109
+ },
1110
+ },
1111
+ },
1047
1112
  ],
1048
1113
  }));
1049
1114
 
@@ -1121,6 +1186,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1121
1186
  else if (name === 'sandbox_list') result = sandboxList();
1122
1187
  else if (name === 'learning_status') result = learningStatus(args);
1123
1188
  else if (name === 'knowledge_graph_ask') result = knowledgeGraphAsk(args);
1189
+ else if (name === 'model_resolve') result = modelResolve(args);
1190
+ else if (name === 'triage_recommend') result = await triageRecommend(args);
1191
+ else if (name === 'workflow_invoke') result = await workflowInvoke(args);
1192
+ else if (name === 'capability_describe') result = capabilityDescribe(args);
1124
1193
  else result = { error: `Unknown tool: ${name}` };
1125
1194
  } catch (err) {
1126
1195
  result = { error: err.message ?? String(err) };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * lib/mcp/tools/embedded-contract.mjs — MCP surface for the Embedded Contract Layer.
3
+ *
4
+ * Thin adapters that translate MCP tool arguments into the embedded-contract
5
+ * core calls and return the same versioned envelope the CLI-JSON and SDK
6
+ * surfaces emit. No business logic lives here — it delegates to
7
+ * lib/embedded-contract/* so the three surfaces stay structurally identical.
8
+ */
9
+
10
+ import { wrapContractResult } from '../../embedded-contract/envelope.mjs';
11
+ import { resolveEmbeddedModel as resolveModelCore } from '../../embedded-contract/model-resolve.mjs';
12
+ import { recommendPlan as recommendPlanCore } from '../../embedded-contract/triage.mjs';
13
+ import { invokeWorkflow as invokeWorkflowCore } from '../../embedded-contract/workflow-invoke.mjs';
14
+ import { buildCapabilityContract } from '../../embedded-contract/capability.mjs';
15
+ import { resolveInput } from '../../embedded-contract/ingest.mjs';
16
+
17
+ async function ingestArgs(args) {
18
+ if (args.input || !args.file_path) return { input: args.input, ingestion: undefined };
19
+ const resolved = await resolveInput({ filePath: args.file_path });
20
+ return { input: resolved.text, ingestion: resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion };
21
+ }
22
+
23
+ function csv(value) {
24
+ if (Array.isArray(value)) return value;
25
+ if (typeof value === 'string' && value.trim()) return value.split(',').map((s) => s.trim()).filter(Boolean);
26
+ return undefined;
27
+ }
28
+
29
+ export function modelResolve(args = {}) {
30
+ const request = {
31
+ workflowType: args.workflow_type,
32
+ requestedTier: args.requested_tier,
33
+ host: args.host,
34
+ hostModel: args.host_model,
35
+ hostProvider: args.host_provider,
36
+ capabilities: csv(args.capabilities),
37
+ allowCrossProviderFallback: Boolean(args.allow_cross_provider_fallback),
38
+ };
39
+ return wrapContractResult(resolveModelCore(request), { surface: 'mcp' });
40
+ }
41
+
42
+ export async function triageRecommend(args = {}) {
43
+ const { input, ingestion } = await ingestArgs(args);
44
+ const request = {
45
+ input,
46
+ ingestion,
47
+ sourcePath: args.source_path || args.file_path,
48
+ artifactType: args.artifact_type,
49
+ domain: args.domain,
50
+ desiredOutcome: args.desired_outcome,
51
+ constraints: csv(args.constraints),
52
+ availableRoles: csv(args.available_roles),
53
+ };
54
+ return wrapContractResult(recommendPlanCore(request), { surface: 'mcp' });
55
+ }
56
+
57
+ export async function workflowInvoke(args = {}) {
58
+ const { input, ingestion } = await ingestArgs(args);
59
+ const request = {
60
+ workflowType: args.workflow_type,
61
+ input,
62
+ ingestion,
63
+ context: args.context,
64
+ roleStrategy: args.role_strategy,
65
+ requestedRoles: csv(args.requested_roles),
66
+ approvalMode: args.approval_mode,
67
+ trace: args.trace !== false,
68
+ host: args.host,
69
+ hostModel: args.host_model,
70
+ hostProvider: args.host_provider,
71
+ };
72
+ return wrapContractResult(await invokeWorkflowCore(request), { surface: 'mcp' });
73
+ }
74
+
75
+ export function capabilityDescribe(args = {}) {
76
+ return wrapContractResult(buildCapabilityContract({ rootDir: args.root_dir }), { surface: 'mcp' });
77
+ }
@@ -18,6 +18,7 @@ import { buildStatus } from '../../status.mjs';
18
18
  import { readEfficiencyLog, buildCompactEfficiencyDigest } from '../../efficiency.mjs';
19
19
  import { addObservation } from '../../observation-store.mjs';
20
20
  import { loadConstructEnv } from '../../env-config.mjs';
21
+ import { createSqlClient, closeSqlClient } from '../../storage/backend.mjs';
21
22
 
22
23
  // Load config.env once at module init so config.env values win over shell env
23
24
  // (shell env may have stale/truncated credentials from earlier sessions)
@@ -243,6 +244,35 @@ export async function cxScore(args) {
243
244
  }
244
245
  }
245
246
 
247
+ // Postgres write-through (team/enterprise). Backs the
248
+ // construct_skill_quality_correlation view that `construct skills
249
+ // correlate-quality` reads — without this, the view stays empty and
250
+ // the CLI surface that promised correlation data delivered nothing.
251
+ // Best-effort: a missing DATABASE_URL or schema misalignment never
252
+ // blocks the score from reaching the remote telemetry or the local
253
+ // observation store above.
254
+
255
+ if (Number.isFinite(numericValue)) {
256
+ const sqlClient = createSqlClient(process.env);
257
+ if (sqlClient) {
258
+ try {
259
+ await sqlClient`
260
+ insert into construct_cx_scores (ts, trace_id, session_id, agent_id, name, value, comment)
261
+ values (
262
+ ${new Date().toISOString()},
263
+ ${traceId},
264
+ ${args.session_id ?? null},
265
+ ${args.agent_id ?? args.name ?? null},
266
+ ${args.name ?? 'quality'},
267
+ ${numericValue},
268
+ ${args.comment ?? null}
269
+ )
270
+ `;
271
+ } catch { /* best-effort write — never block on DB issues */ }
272
+ finally { await closeSqlClient(sqlClient).catch(() => {}); }
273
+ }
274
+ }
275
+
246
276
  return { ok: true, traceId, backend: client.backend, remoteStatus: client.remoteStatus };
247
277
  } catch (err) {
248
278
  return { ok: false, error: err.message };
@@ -418,6 +418,46 @@ export function resolveTiersForPrimary(primaryModelId) {
418
418
  return family.resolve({ reasoning: undefined, standard: undefined, fast: undefined });
419
419
  }
420
420
 
421
+ // Credential detection here is env-only — no network or process probe — so
422
+ // callers (embedded model resolution, capability discovery) stay synchronous
423
+ // and deterministic, and never expose a key value, only its presence.
424
+
425
+ function familyDescriptor(family, env) {
426
+ const requiresEnv = PROVIDER_ENV_MAP[family.id] || (Array.isArray(family.requiresEnv) ? family.requiresEnv : []);
427
+ const local = family.local === true;
428
+ const configured = local || requiresEnv.some((key) => {
429
+ const value = env?.[key];
430
+ return typeof value === "string" && value.length > 0;
431
+ });
432
+ return {
433
+ id: family.id,
434
+ label: family.label,
435
+ local,
436
+ requiresEnv,
437
+ tiers: family.resolve({}),
438
+ configured,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Describe the provider family a model id belongs to, including which env keys
444
+ * confirm credentials and whether they are present. Returns null when no family
445
+ * matches.
446
+ */
447
+ export function describeModelFamily(modelId, { env = process.env } = {}) {
448
+ const family = matchProviderFamily(modelId);
449
+ if (!family) return null;
450
+ return familyDescriptor(family, env);
451
+ }
452
+
453
+ /**
454
+ * Describe every provider family. Resolves a host-supplied provider id and
455
+ * enumerates model availability for the embedded contract layer.
456
+ */
457
+ export function listModelFamilies({ env = process.env } = {}) {
458
+ return PROVIDER_FAMILY_TIERS.map((family) => familyDescriptor(family, env));
459
+ }
460
+
421
461
  /**
422
462
  * Read the OpenRouter API key from the OpenCode config file.
423
463
  */
package/lib/op-log.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * lib/op-log.mjs — per-operation structured log file for long-running CLI ops.
3
+ *
4
+ * Commands like `dev`, `install`, `init`, and `sync` keep their human-facing
5
+ * output on stdout (println/ok/warn). This module adds a parallel machine log:
6
+ * one JSONL file per run at `<home>/.cx/<op>-<timestamp>.log`, every line
7
+ * stamped with a shared correlation id (`op_id`) so a failed run can be
8
+ * reconstructed after the fact and concurrent runs never interleave
9
+ * ambiguously. It reuses `makeLogger` from lib/logger.mjs for the line format.
10
+ *
11
+ * Writes are synchronous (an open fd + writeSync), not a buffered stream:
12
+ * CLI ops routinely end in process.exit(), and a buffered stream can drop its
13
+ * tail before the event loop drains it. Synchronous append loses nothing.
14
+ *
15
+ * Best-effort by contract: if the log dir can't be created or the file can't
16
+ * open, the returned handle silently no-ops. Logging must never break the
17
+ * operation it observes.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import os from 'node:os';
23
+
24
+ import { makeLogger, newRequestId } from './logger.mjs';
25
+
26
+ const NOOP_LOGGER = Object.freeze({ debug() {}, info() {}, warn() {}, error() {} });
27
+
28
+ export function startOpLog(op, { homeDir = os.homedir(), env = process.env, now = new Date() } = {}) {
29
+ const id = newRequestId();
30
+ const dir = path.join(homeDir, '.cx');
31
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
32
+ const logPath = path.join(dir, `${op}-${stamp}.log`);
33
+
34
+ let fd = null;
35
+ try {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ fd = fs.openSync(logPath, 'a');
38
+ } catch {
39
+ fd = null;
40
+ }
41
+
42
+ // Adapt the fd to the minimal stream surface makeLogger needs: a sync write.
43
+ const stream = fd === null ? null : { write: (line) => { try { fs.writeSync(fd, line); } catch { /* best-effort */ } } };
44
+ const log = stream
45
+ ? makeLogger({ env, stream }).child({ op, op_id: id })
46
+ : NOOP_LOGGER;
47
+
48
+ log.info('op.start', { pid: process.pid });
49
+
50
+ return {
51
+ id,
52
+ logPath: fd === null ? null : logPath,
53
+ event: (name, fields = {}) => log.info(name, fields),
54
+ warn: (name, fields = {}) => log.warn(name, fields),
55
+ error: (name, fields = {}) => log.error(name, fields),
56
+ close: (status = 'ok', fields = {}) => {
57
+ log.info('op.end', { status, ...fields });
58
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } fd = null; }
59
+ },
60
+ };
61
+ }
@@ -352,6 +352,21 @@ export function classifyRoleFlavors(request = '') {
352
352
  };
353
353
  }
354
354
 
355
+ // One line per non-null flavor: "cx-<role>: loaded <role>.<flavor>
356
+ // overlay". Verbose chat surface + cx_trace span attribute; lets a
357
+ // post-hoc reviewer see which overlays drove a given dispatch.
358
+
359
+ export function formatOverlaySelection(roleFlavors) {
360
+ if (!roleFlavors || typeof roleFlavors !== 'object') return [];
361
+ const lines = [];
362
+ for (const [role, flavor] of Object.entries(roleFlavors)) {
363
+ if (!flavor) continue;
364
+ const specialist = `cx-${role.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
365
+ lines.push(`${specialist}: loaded ${role}.${flavor} overlay`);
366
+ }
367
+ return lines;
368
+ }
369
+
355
370
  export function detectRiskFlags(request = '') {
356
371
  const text = String(request).toLowerCase();
357
372
  return {
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * lib/roles/catalog.mjs — role listing and display helpers.
3
3
  *
4
- * Reads specialists/registry.json to produce a list of available roles with
5
- * optional department grouping and consolidated-role views. Consumed by the
6
- * `roles:list` CLI command.
4
+ * Reads the flat `specialists` array in specialists/registry.json to produce the
5
+ * list of available roles. Each descriptor carries the registry-prefixed display
6
+ * name (cx-<name>), description, model tier, edit capability, and skill ids — the
7
+ * shape consumed by the `roles:list` CLI command and the embedded capability
8
+ * contract (lib/embed/capability.mjs).
9
+ *
10
+ * The registry once carried `departments` and `consolidatedRoles` groupings; the
11
+ * schema since collapsed to a single `specialists` array, so this reader exposes
12
+ * the flat list only and never invents grouping that has no source.
7
13
  */
8
14
 
9
15
  import { readFileSync } from 'node:fs';
@@ -22,109 +28,34 @@ function loadRegistry() {
22
28
  }
23
29
 
24
30
  /**
25
- * Return a list of role descriptors.
31
+ * Return the list of role descriptors from the registry's specialists array.
26
32
  *
27
- * @param {object} opts
28
- * @param {boolean} opts.departments Group by department when true.
29
- * @param {boolean} opts.consolidated Return consolidated 12-role view when true.
30
- * @returns {Array<object>}
33
+ * @returns {Array<{id:string,name:string,description:string,modelTier:string,internal:boolean,canEdit:boolean,skills:string[]}>}
31
34
  */
32
- export function listRoles({ departments = false, consolidated = false } = {}) {
35
+ export function listRoles() {
33
36
  const registry = loadRegistry();
37
+ const prefix = registry.prefix || 'cx';
38
+ const specialists = Array.isArray(registry.specialists) ? registry.specialists : [];
34
39
 
35
- if (consolidated) {
36
- const c = registry.consolidatedRoles || {};
37
- return (c.roles || []).map((r) => ({
38
- id: r.id,
39
- absorbs: r.absorbs || [],
40
- whenToUse: r.whenToUse || '',
41
- }));
42
- }
43
-
44
- const agents = Array.isArray(registry.agents)
45
- ? registry.agents
46
- : Object.values(registry.agents || {});
47
-
48
- if (!departments) {
49
- return agents.map((a) => ({
50
- id: a.name,
51
- name: `cx-${a.name}`,
52
- description: a.description || '',
53
- internal: !!a.internal,
54
- }));
55
- }
56
-
57
- // Group by department based on subDepartment membership in registry.departments
58
- const depts = registry.departments || {};
59
- const grouped = {};
60
- for (const [deptId, dept] of Object.entries(depts)) {
61
- grouped[deptId] = {
62
- name: dept.name || deptId,
63
- roles: [],
64
- };
65
- }
66
- grouped._ungrouped = { name: 'Other', roles: [] };
67
-
68
- for (const agent of agents) {
69
- let placed = false;
70
- for (const [deptId, dept] of Object.entries(depts)) {
71
- const subs = dept.subDepartments || {};
72
- for (const sub of Object.values(subs)) {
73
- if ((sub.roles || []).includes(agent.name) || (sub.roles || []).includes(`cx-${agent.name}`)) {
74
- grouped[deptId].roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
75
- placed = true;
76
- break;
77
- }
78
- }
79
- if (placed) break;
80
- }
81
- if (!placed) {
82
- grouped._ungrouped.roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
83
- }
84
- }
85
-
86
- return Object.entries(grouped)
87
- .filter(([, g]) => g.roles.length > 0)
88
- .map(([id, g]) => ({ departmentId: id, departmentName: g.name, roles: g.roles }));
40
+ return specialists.map((s) => ({
41
+ id: s.name,
42
+ name: `${prefix}-${s.name}`,
43
+ description: s.description || '',
44
+ modelTier: s.modelTier || 'standard',
45
+ internal: !!s.internal,
46
+ canEdit: !!s.canEdit,
47
+ skills: Array.isArray(s.skills) ? s.skills : [],
48
+ }));
89
49
  }
90
50
 
91
51
  /**
92
52
  * Format the role list as a human-readable string.
93
53
  *
94
- * @param {object} opts Same options as listRoles.
95
54
  * @returns {string}
96
55
  */
97
- export function formatRoleList({ departments = false, consolidated = false } = {}) {
98
- const roles = listRoles({ departments, consolidated });
99
- const lines = [];
100
-
101
- if (consolidated) {
102
- lines.push('Consolidated Roles (12-role simplified structure)');
103
- lines.push('=================================================');
104
- for (const r of roles) {
105
- lines.push(`\n${r.id}`);
106
- lines.push(` Absorbs: ${r.absorbs.join(', ')}`);
107
- lines.push(` Use when: ${r.whenToUse}`);
108
- }
109
- lines.push('');
110
- return lines.join('\n');
111
- }
112
-
113
- if (departments) {
114
- lines.push('Available Roles by Department');
115
- lines.push('=============================');
116
- for (const group of roles) {
117
- lines.push(`\n${group.departmentName}`);
118
- for (const role of group.roles) {
119
- lines.push(` ${role.name.padEnd(30)} ${role.description}`);
120
- }
121
- }
122
- lines.push('');
123
- return lines.join('\n');
124
- }
125
-
126
- lines.push('Available Roles');
127
- lines.push('===============');
56
+ export function formatRoleList() {
57
+ const roles = listRoles();
58
+ const lines = ['Available Roles', '==============='];
128
59
  for (const role of roles) {
129
60
  lines.push(` ${role.name.padEnd(30)} ${role.description}`);
130
61
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
- import { homedir } from 'node:os';
14
+ import { homedir, tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
 
17
17
  import { emit, _paths } from './event-bus.mjs';
@@ -54,7 +54,36 @@ function appendPending(entry) {
54
54
  appendFileSync(pendingPath(), JSON.stringify(entry) + '\n');
55
55
  }
56
56
 
57
+ // Paths under the OS tmpdir are by definition test fixtures or sandbox
58
+ // runs, not real project state. Escalating from them spawns persistent
59
+ // beads for ephemeral files — pure tracker noise. macOS resolves
60
+ // $TMPDIR through a /private/var/folders/... symlink; the raw tmpdir
61
+ // and its /private prefix are both checked so either resolved form
62
+ // matches.
63
+
64
+ export function isTestFixturePath(p) {
65
+ if (!p || typeof p !== 'string') return false;
66
+ const tmp = tmpdir();
67
+ if (p.startsWith(tmp)) return true;
68
+ if (p.startsWith('/private' + tmp)) return true;
69
+ if (p.startsWith('/tmp/')) return true;
70
+ // macOS canonical user-tmp prefix when $TMPDIR is set elsewhere
71
+ if (/^\/private\/var\/folders\/[^/]+\/[^/]+\/T\//.test(p)) return true;
72
+ if (/^\/var\/folders\/[^/]+\/[^/]+\/T\//.test(p)) return true;
73
+ return false;
74
+ }
75
+
76
+ function eventIsFromTestFixture(event) {
77
+ if (isTestFixturePath(event?.cwd)) return true;
78
+ if (isTestFixturePath(event?.project)) return true;
79
+ if (isTestFixturePath(event?.context?.filePath)) return true;
80
+ return false;
81
+ }
82
+
57
83
  export function shouldEscalate(event, manifest, now = Date.now()) {
84
+ if (eventIsFromTestFixture(event)) {
85
+ return { escalate: false, reason: 'test-fixture-path' };
86
+ }
58
87
  const fp = event.fingerprint;
59
88
  const pending = readPending();
60
89
 
@@ -94,14 +94,34 @@ registerJob({
94
94
  handler: async () => ({ status: 'noop', reason: 'not yet implemented' }),
95
95
  });
96
96
 
97
+ // Doc hygiene scan cadence is deployment-aware. Solo runs nightly because
98
+ // a single contributor's doc drift accumulates slowly. Team and enterprise
99
+ // runs hourly because many writers can shift the surface within a workday,
100
+ // and the higher limit (50 vs 25) gives the reconcile worker enough headroom
101
+ // to keep up. Mode/schedule resolved at module-load so a single registry
102
+ // entry covers both topologies.
103
+
104
+ const DOC_HYGIENE_SOLO_CRON = '0 2 * * *';
105
+ const DOC_HYGIENE_TEAM_CRON = '0 * * * *';
106
+
107
+ export function resolveDocHygieneSchedule(env = process.env) {
108
+ const mode = getDeploymentMode(env);
109
+ const isTeamish = mode === 'team' || mode === 'enterprise';
110
+ return {
111
+ mode: isTeamish ? 'team' : 'solo',
112
+ schedule: isTeamish ? DOC_HYGIENE_TEAM_CRON : DOC_HYGIENE_SOLO_CRON,
113
+ limit: isTeamish ? 50 : 25,
114
+ };
115
+ }
116
+
117
+ const docHygiene = resolveDocHygieneSchedule();
97
118
  registerJob({
98
119
  id: 'doc-hygiene-scan',
99
- schedule: '0 2 * * *',
100
- mode: 'solo',
120
+ schedule: docHygiene.schedule,
121
+ mode: docHygiene.mode,
101
122
  handler: async ({ cwd, env }) => {
102
123
  const { findHygieneCandidates } = await import('../hygiene/scan.mjs');
103
- const mode = getDeploymentMode(env);
104
- const limit = mode === 'solo' ? 25 : 50;
124
+ const { limit } = resolveDocHygieneSchedule(env);
105
125
  const candidates = findHygieneCandidates({ cwd, limit });
106
126
  return {
107
127
  status: 'ok',
@@ -14,6 +14,7 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { readCostLog, summarizeCostData } from '../cost.mjs';
17
+ import { getRebrand } from '../profiles/rebrand.mjs';
17
18
 
18
19
  const TELEMETRY_TIMEOUT_MS = 3500;
19
20
 
@@ -309,12 +310,23 @@ function summarizeIntakeQueue(env, cwd = process.cwd()) {
309
310
  }
310
311
  } catch { /* skip */ }
311
312
 
313
+ // Profile-aware labels: the active profile's rebrand block (e.g.
314
+ // rnd → "Intake queue"/"signal"; sales-ops → "Lead inbox"/"lead")
315
+ // travels in the payload so the dashboard renders the right noun
316
+ // without hardcoding "intake" anywhere user-facing.
317
+
318
+ let rebrand;
319
+ try { rebrand = getRebrand(cwd); }
320
+ catch { rebrand = { intakeQueueLabel: 'Intake queue', signalNoun: 'signal' }; }
321
+
312
322
  return {
313
323
  state: pendingCount === 0 && processedCount === 0 && skippedCount === 0 ? 'empty' : 'ok',
314
324
  pending: pendingCount,
315
325
  processed: processedCount,
316
326
  skipped: skippedCount,
317
327
  customerLinked,
328
+ label: rebrand.intakeQueueLabel,
329
+ itemNoun: rebrand.signalNoun,
318
330
  };
319
331
  } catch (err) {
320
332
  return { state: 'unreachable', message: err.message };